React Native · Reanimated 3

Animaciones 60fps — NutriTrack App

Tres componentes animados con worklets en el hilo nativo: sin caídas de frames incluso en Android de gama media.

Cliente: NutriTrack
Stack: Expo + RN + NativeWind
Target: 60fps · Snapdragon 665
Skill: animaciones-react-native-reanimated
JS Thread
React render
State updates
API calls
Declara worklets
Redux / Zustand
JSI
UI Thread (nativo)
useSharedValue
useAnimatedStyle
withSpring / withTiming
interpolate
Gesture worklets

Los worklets (funciones marcadas con 'worklet') se compilan al runtime nativo y nunca cruzan el puente — garantía de 60fps.

FadeInCard.tsx
useSharedValue + useAnimatedStyle + withTiming + withSpring
Hook animation
Vista en dispositivo
9:41
Calorías restantes
832 kcal
142g
Proteína
210g
Carbs
48g
Grasa
🌅
Desayuno · 7:30
Avena + banana — 380 kcal
🥗
Comida · 14:15
Ensalada pollo — 520 kcal
↑ fade + spring al montar
// components/FadeInCard.tsx — NutriTrack
import Animated, {
  useSharedValue, useAnimatedStyle,
  withTiming, withSpring,
} from 'react-native-reanimated';
import { useEffect } from 'react';

export function FadeInCard({ children }) {
  // Valores viven en el UI thread
  const opacity = useSharedValue(0);
  const translateY = useSharedValue(20);

  useEffect(() => {
    // Timing para fade preciso (600ms)
    opacity.value = withTiming(1, { duration: 600 });
    // Spring para slide natural (sin rebote exagerado)
    translateY.value = withSpring(0, {
      damping: 15, stiffness: 120,
    });
  }, []);

  // Worklet — se ejecuta en UI thread
  const style = useAnimatedStyle(() => ({
    opacity: opacity.value,
    transform: [{ translateY: translateY.value }],
  }));

  return (
    <Animated.View style={style}
      className="bg-green-600 rounded-xl p-4">
      {children}
    </Animated.View>
  );
}
SwipeToLogMeal.tsx
Gesture.Pan() + useSharedValue + runOnJS + withSpring
Gesture driven
Estado: swipe completado
Sugerencias de hoy
Pollo con arroz
487 kcal
320g
Batido proteico
220 kcal
300ml
Fruta de temporada
95 kcal
180g
← desliza para registrar
spring snap al 60% umbral
// components/SwipeToLogMeal.tsx
import {
  useSharedValue, useAnimatedStyle,
  withSpring, runOnJS,
} from 'react-native-reanimated';
import {
  Gesture, GestureDetector,
} from 'react-native-gesture-handler';

const THRESHOLD = -120; // 60% del ancho de acción

export function SwipeToLogMeal({ onLog, children }) {
  const translateX = useSharedValue(0);

  const pan = Gesture.Pan()
    .onUpdate((e) => {
      // Solo izquierda (negativo)
      translateX.value = Math.min(0, e.translationX);
    })
    .onEnd((e) => {
      if (e.translationX < THRESHOLD) {
        // Registrar — vuelve a JS thread con runOnJS
        translateX.value = withSpring(-400);
        runOnJS(onLog)();
      } else {
        // Snap-back con spring natural
        translateX.value = withSpring(0, {
          damping: 18, stiffness: 150,
        });
      }
    });

  const style = useAnimatedStyle(() => ({
    transform: [{ translateX: translateX.value }],
  }));

  return (
    <GestureDetector gesture={pan}>
      <Animated.View style={style}>
        {children}
      </Animated.View>
    </GestureDetector>
  );
}
ParallaxHeaderNutri.tsx
useAnimatedScrollHandler + interpolate + useAnimatedStyle
Scroll driven
Scroll = 160px (compacto)
L
Lucía · 832 kcal restantes
Objetivo: 1.800 kcal/día
REGISTRO DE HOY
🌅 Desayuno — Avena 380 kcal
🥗 Comida — Ensalada 520 kcal
☕ Snack — Café con leche 68 kcal
↑ header colapsado al hacer scroll
interpolate → compact mode
// components/ParallaxHeaderNutri.tsx
import Animated, {
  useAnimatedScrollHandler,
  useSharedValue, useAnimatedStyle,
  interpolate,
} from 'react-native-reanimated';

export function ParallaxHeaderNutri() {
  const scrollY = useSharedValue(0);

  // Worklet — corre en UI thread en cada frame
  const onScroll = useAnimatedScrollHandler({
    onScroll: (event) => {
      scrollY.value = event.contentOffset.y;
    },
  });

  // Header: 300 → 80px, opacidad 1 → 0.2
  const headerStyle = useAnimatedStyle(() => ({
    height: interpolate(
      scrollY.value,
      [0, 160], [300, 80]
    ),
    opacity: interpolate(
      scrollY.value,
      [0, 160], [1, 0.15]
    ),
    transform: [{
      translateY: interpolate(
        scrollY.value,
        [0, 160], [0, -60]
      ),
    }],
  }));

  return (
    <>
      <Animated.View
        style={headerStyle}
        className="bg-green-800">
        <NutriHeaderContent />
      </Animated.View>
      <Animated.ScrollView
        onScroll={onScroll}
        scrollEventThrottle={16}>
        <MealLog />
      </Animated.ScrollView>
    </>
  );
}
🔗
useSharedValue
Almacena el estado de animación en el UI thread. Cambios a .value disparan re-renderizado nativo sin pasar por React.
🎨
useAnimatedStyle
Worklet que devuelve estilos calculados en cada frame en el UI thread. Sin re-renders de React — pura actualización nativa.
🤌
Gesture.Pan()
Los callbacks onUpdate / onEnd son worklets. El gesto se procesa completamente en el hilo nativo a 120Hz.
📈
interpolate
Mapea un rango de entrada (scrollY) a un rango de salida (height, opacity). Worklet puro — 0ms de latencia JS.
Plugin Babel al finalreact-native-reanimated/plugin siempre el último en el array de plugins.
scrollEventThrottle={16} En ScrollView animados para asegurar eventos a 60fps exactos.
runOnJS para callbacks Usa runOnJS(onLog)() para volver al hilo JS desde un worklet de gesto.
withSpring para gestos Usa damping: 15–20 y stiffness: 120–180 para sensación natural.
⚠️
No acceder a objetos JS en worklets Solo primitivos y SharedValues — nada de arrays, objetos React ni closures complejos.
Layout animations declarativas Usa entering/exiting en listas de comidas — Reanimated gestiona el diffing automáticamente.