← Regresar a lecciones
  • Reanimated

  • Gestures

  • Typescript

  • Animations

  • react native

  • performance

  • FlatList

  • Gesture Handler

Listas, Animaciones y Gestos en React Native

Setup Inicial

La diferencia entre una aplicación móvil que se siente nativa y una que parece web envuelta en un contenedor está en los detalles, listas que se deslizan suavemente sin tartamudeos, animaciones que responden instantáneamente a los toques del usuario, y gestos que se sienten naturales como en Instagram o Twitter. En React Native, lograr este nivel de pulimiento requiere entender cómo funciona el renderizado bajo el capó y usar las herramientas correctas.

El problema fundamental es que JavaScript corre en un thread separado del UI thread nativo. Cuando haces scroll en una lista, si el JavaScript thread está ocupado calculando qué items mostrar o actualizando estados, la interfaz se congela. Cuando animas un elemento con Animated tradicional, cada frame necesita comunicarse entre threads a través del bridge, causando drops de frames. Y cuando el usuario arrastra algo con PanResponder, la latencia entre el gesto y la respuesta visual es perceptible.

Setup Inicial

Antes de sumergirnos en el código, necesitamos configurar tres librerías fundamentales que transformarán tu app. Cada una resuelve un problema específico de performance y experiencia de usuario.

Instalación Base

1npm install react-native-reanimated react-native-gesture-handler @shopify/flash-list 2 3cd ios && pod install && cd ..

Una vez instaladas, necesitas configurarlas correctamente. A diferencia de librerías JavaScript puras, estas trabajan directamente con código nativo y requieren modificaciones en la configuración del proyecto.

Configurando Reanimated: Animaciones en el UI Thread

Reanimated necesita un plugin de Babel que transforma tu código JavaScript en worklets, funciones que pueden ejecutarse directamente en el UI thread nativo sin pasar por el bridge. Esto es lo que permite animaciones fluidas incluso cuando el JavaScript thread está ocupado.

Modifica tu babel.config.js:

1module.exports = { 2 presets: ['module:metro-react-native-babel-preset'], 3 plugins: [ 4 'react-native-reanimated/plugin', // Debe ser el último plugin siempre 5 ], 6};

Es crucial que este plugin esté al final porque necesita procesar el código después de todas las demás transformaciones. Si lo colocas antes de otros plugins, las animaciones no funcionarán correctamente.

Configurando Gesture Handler: Toques Nativos

Gesture Handler intercepta eventos de toque antes que React Native los procese, permitiendo respuestas instantáneas. Para que funcione, tu árbol de componentes debe estar envuelto en un contexto especial.

En App.tsx:

1import { GestureHandlerRootView } from 'react-native-gesture-handler'; 2 3export default function App() { 4 return ( 5 <GestureHandlerRootView style={{ flex: 1 }}> 6 {/* Tu aplicación aquí */} 7 </GestureHandlerRootView> 8 ); 9}

Este componente configura el sistema de gestos nativo para toda tu app. Sin él, los gestos simplemente no funcionarán.

Para Android, también necesitas modificar MainActivity.java (o .kt si usas Kotlin):

1import com.facebook.react.ReactActivityDelegate; 2import com.facebook.react.defaults.DefaultReactActivityDelegate; 3 4@Override 5protected ReactActivityDelegate createReactActivityDelegate() { 6 return new DefaultReactActivityDelegate( 7 this, 8 getMainComponentName(), 9 DefaultNewArchitectureEntryPoint.getFabricEnabled() 10 ); 11}

Después de estos cambios, es fundamental hacer un rebuild completo de tu app. Los cambios en código nativo no se reflejan con hot reload.

Listas de Alto Rendimiento

El componente FlatList de React Native parece simple, pero cuando lo usas con configuración default en listas grandes, verás lag, scroll entrecortado y consumo excesivo de memoria. El problema es que FlatList intenta ser inteligente midiendo dinámicamente el tamaño de cada item, lo cual es costoso. Necesitamos darle información explícita para que deje de adivinar.

getItemLayout: El Secreto del Scroll Instantáneo

Imagina una lista de 10,000 contactos. Sin getItemLayout, cuando el usuario hace scroll rápido al final, FlatList necesita calcular la posición exacta midiendo mentalmente cada uno de los items previos. Es como tratar de encontrar la página 500 de un libro sin números de página. Con getItemLayout, le das a FlatList una fórmula matemática simple para calcular cualquier posición instantáneamente.

1interface Contact { 2 id: string; 3 name: string; 4 email: string; 5 phone: string; 6} 7 8const ITEM_HEIGHT = 80; 9const SEPARATOR_HEIGHT = 1; 10 11const ContactList: React.FC = () => { 12 const [contacts, setContacts] = useState<Contact[]>([]); 13 14 const renderItem = ({ item }: { item: Contact }) => ( 15 <ContactCard contact={item} /> 16 ); 17 18 const getItemLayout = ( 19 data: Contact[] | null | undefined, 20 index: number 21 ) => ({ 22 length: ITEM_HEIGHT, 23 offset: (ITEM_HEIGHT + SEPARATOR_HEIGHT) * index, 24 index, 25 }); 26 27 return ( 28 <FlatList 29 data={contacts} 30 renderItem={renderItem} 31 getItemLayout={getItemLayout} 32 keyExtractor={item => item.id} 33 /> 34 ); 35};

El offset es la posición Y donde comienza cada item. Si cada item mide 80px y hay un separador de 1px, el item 0 está en posición 0, el item 1 en posición 81, el item 2 en posición 162, y así sucesivamente. Esta simple multiplicación evita que FlatList tenga que renderizar y medir items fuera de pantalla para calcular posiciones. El resultado es scroll instantáneo incluso con decenas de miles de items.

Por supuesto, esto solo funciona si tus items tienen altura fija o predecible. Si cada item tiene altura variable (como posts con texto de longitud diferente), no puedes usar getItemLayout y necesitarás otras optimizaciones.

windowSize: Balanceando Memoria y Suavidad

Por default, FlatList mantiene renderizados 21 "pantallas" de items: 10 hacia arriba, la actual, y 10 hacia abajo. Esto consume mucha memoria innecesariamente. El truco está en encontrar el balance perfecto donde la lista se siente fluida pero no cargas datos que el usuario probablemente nunca verá.

1interface Product { 2 id: string; 3 name: string; 4 price: number; 5 imageUrl: string; 6} 7 8const ProductList: React.FC<{ products: Product[] }> = ({ products }) => ( 9 <FlatList 10 data={products} 11 renderItem={({ item }) => <ProductCard product={item} />} 12 windowSize={5} 13 maxToRenderPerBatch={10} 14 updateCellsBatchingPeriod={50} 15 initialNumToRender={10} 16 /> 17);

El windowSize de 5 significa que mantenemos 2 pantallas arriba, la pantalla actual, y 2 pantallas abajo. Esto reduce el uso de memoria drásticamente sin sacrificar la experiencia. El maxToRenderPerBatch controla cuántos items se procesan por frame durante el scroll, un número bajo (como 10) previene que el JavaScript thread se sature. El updateCellsBatchingPeriod de 50ms establece la frecuencia de actualización del batch, dándole al thread tiempo para respirar entre renders. Finalmente, initialNumToRender determina cuántos items mostrar inmediatamente en el mount inicial, evitando la temida pantalla en blanco mientras se cargan datos.

removeClippedSubviews: Liberación Agresiva en Android

Esta es una optimización específica de Android que literalmente remueve las vistas nativas que están fuera de pantalla del árbol de vistas. En iOS, el sistema ya hace esto automáticamente, pero en Android necesitas activarlo explícitamente.

1interface Message { 2 id: string; 3 text: string; 4 sender: string; 5 timestamp: number; 6} 7 8const MESSAGE_HEIGHT = 60; 9 10const ChatMessages: React.FC<{ messages: Message[] }> = ({ messages }) => ( 11 <FlatList 12 data={messages} 13 renderItem={({ item }) => <MessageBubble message={item} />} 14 removeClippedSubviews={Platform.OS === 'android'} 15 windowSize={7} 16 getItemLayout={(data, index) => ({ 17 length: MESSAGE_HEIGHT, 18 offset: MESSAGE_HEIGHT * index, 19 index, 20 })} 21 /> 22);

La advertencia importante es que removeClippedSubviews puede causar bugs visuales si tus items tienen layouts complejos con elementos posicionados de forma absoluta o animaciones. Pruébalo extensivamente antes de dejarlo en producción.

FlashList: Cuando FlatList No Es Suficiente

Shopify construyó FlashList después de enfrentarse a problemas de performance en su app con miles de productos. La diferencia fundamental está en cómo recicla las vistas. Mientras FlatList destruye y recrea componentes constantemente, FlashList reutiliza agresivamente los componentes existentes, solo cambiando sus props. Es como tener actores que cambian de vestuario en lugar de contratar actores nuevos para cada escena.

1import { FlashList } from "@shopify/flash-list"; 2 3interface InventoryItem { 4 sku: string; 5 name: string; 6 stock: number; 7 warehouse: string; 8} 9 10const InventoryList: React.FC<{ items: InventoryItem[] }> = ({ items }) => ( 11 <FlashList 12 data={items} 13 renderItem={({ item }) => <InventoryCard item={item} />} 14 estimatedItemSize={120} 15 keyExtractor={item => item.sku} 16 /> 17);

El estimatedItemSize es crucial para que FlashList calcule cuántos items preparar. No necesita ser exacto, pero cuanto más cercano al promedio real, mejor será la performance. FlashList funciona especialmente bien con listas heterogéneas donde los items tienen alturas variables, algo donde FlatList tradicionalmente sufre.

La pregunta es cuándo usar FlashList sobre FlatList. Si tu lista tiene más de 100 items, especialmente si tienen alturas variables o imágenes, FlashList probablemente te dará mejor performance. El único trade-off es que agregas una dependencia extra a tu proyecto.

Reanimated 3: Animaciones Verdaderamente Nativas

El problema con las animaciones tradicionales en React Native es que cada frame necesita comunicación entre el JavaScript thread y el UI thread. Si tu JavaScript está ocupado procesando algo, la animación se congela. Reanimated 3 resuelve esto ejecutando las animaciones completamente en el UI thread nativo usando "worklets", funciones JavaScript especiales que se serializan y envían al lado nativo una sola vez.

Los Fundamentos: Shared Values y Animated Styles

Un SharedValue es un valor que vive simultáneamente en ambos threads. Cuando lo modificas desde JavaScript, el cambio se propaga automáticamente al UI thread sin pasar por el bridge. Es la pieza fundamental de cualquier animación performante.

1import Animated, { 2 useSharedValue, 3 useAnimatedStyle, 4 withSpring, 5} from 'react-native-reanimated'; 6 7const PulsingHeart: React.FC = () => { 8 const scale = useSharedValue(1); 9 const [liked, setLiked] = useState(false); 10 11 const animatedStyle = useAnimatedStyle(() => ({ 12 transform: [{ scale: scale.value }], 13 })); 14 15 const onPress = () => { 16 scale.value = withSpring(liked ? 1 : 1.3, { 17 damping: 10, 18 stiffness: 100, 19 }); 20 setLiked(!liked); 21 }; 22 23 return ( 24 <TouchableOpacity onPress={onPress}> 25 <Animated.View style={animatedStyle}> 26 <Heart color={liked ? 'red' : 'gray'} /> 27 </Animated.View> 28 </TouchableOpacity> 29 ); 30};

El useAnimatedStyle es un worklet, nota cómo la función dentro accede a scale.value. Este código no corre en JavaScript, corre en el UI thread nativo. El withSpring también es un worklet que calcula la física del spring directamente en el lado nativo. El resultado es una animación que corre a 60 FPS incluso si el JavaScript thread está completamente bloqueado procesando datos.

Los parámetros de withSpring controlan la física de la animación. Un damping bajo (como 10) crea más rebote, mientras que un stiffness alto (como 100) hace la animación más "tensa" y rápida. Experimenta con estos valores hasta encontrar el feeling que buscas.

Layout Animations: Magia Automática

Las layout animations son quizás la feature más impresionante de Reanimated 3. En lugar de calcular manualmente las posiciones inicial y final de un elemento, simplemente declaras qué tipo de animación quieres y Reanimated se encarga del resto. Es especialmente poderoso para listas donde items aparecen y desaparecen constantemente.

1import Animated, { 2 FadeIn, 3 FadeOut, 4 SlideInRight, 5 SlideOutLeft, 6 Layout, 7} from 'react-native-reanimated'; 8 9interface Task { 10 id: string; 11 title: string; 12 completed: boolean; 13} 14 15const TaskList: React.FC<{ tasks: Task[] }> = ({ tasks }) => ( 16 <View> 17 {tasks.map((task, index) => ( 18 <Animated.View 19 key={task.id} 20 entering={SlideInRight.delay(index * 50).duration(300)} 21 exiting={SlideOutLeft.duration(200)} 22 layout={Layout.springify()} 23 > 24 <TaskCard task={task} /> 25 </Animated.View> 26 ))} 27 </View> 28);

Cuando un nuevo task aparece en el array, automáticamente se desliza desde la derecha con un pequeño delay basado en su índice, creando un efecto de cascada. Cuando se elimina, sale hacia la izquierda. Y lo más impresionante: cuando otros items cambian de posición (porque uno se eliminó), el layout={Layout.springify()} anima automáticamente el reposicionamiento con física de spring. Todo esto sin una sola línea de cálculo manual de coordenadas.

El .delay() y .duration() son métodos encadenables que personalizan la animación. Puedes combinar múltiples modificadores para crear efectos complejos de forma declarativa.

Interpolaciones: De Scroll a Paralaje

Las interpolaciones convierten un rango de valores en otro rango de valores de forma fluida. Son perfectas para crear efectos donde múltiples propiedades visuales cambian en sincronía basadas en una sola fuente de verdad, como la posición del scroll.

1import { 2 interpolate, 3 Extrapolate, 4 useAnimatedScrollHandler 5} from 'react-native-reanimated'; 6 7const HEADER_MAX_HEIGHT = 300; 8const HEADER_MIN_HEIGHT = 100; 9 10const ParallaxHeader: React.FC = () => { 11 const scrollY = useSharedValue(0); 12 13 const headerStyle = useAnimatedStyle(() => { 14 const height = interpolate( 15 scrollY.value, 16 [0, 200], 17 [HEADER_MAX_HEIGHT, HEADER_MIN_HEIGHT], 18 Extrapolate.CLAMP 19 ); 20 21 const opacity = interpolate( 22 scrollY.value, 23 [0, 100, 200], 24 [1, 0.7, 0], 25 Extrapolate.CLAMP 26 ); 27 28 return { height, opacity }; 29 }); 30 31 const scrollHandler = useAnimatedScrollHandler({ 32 onScroll: (event) => { 33 scrollY.value = event.contentOffset.y; 34 }, 35 }); 36 37 return ( 38 <> 39 <Animated.View style={[styles.header, headerStyle]}> 40 <Image source={headerImage} style={styles.headerImage} /> 41 </Animated.View> 42 43 <Animated.ScrollView onScroll={scrollHandler} scrollEventThrottle={16}> 44 <Content /> 45 </Animated.ScrollView> 46 </> 47 ); 48};

Aquí la magia está en la interpolación. Cuando el scrollY va de 0 a 200, la altura del header va de 300 a 100 píxeles de forma proporcional. Simultáneamente, la opacidad va de completamente visible (1) a invisible (0), pero con un punto intermedio en scrollY=100 donde tiene opacidad 0.7. El Extrapolate.CLAMP asegura que los valores no se salgan de los rangos definidos si el usuario hace over-scroll.

El scrollEventThrottle={16} es importante porque determina cada cuántos milisegundos se reportan eventos de scroll. 16ms equivale a aproximadamente 60 FPS, el balance perfecto entre precisión y performance.

Gesture Handler v2: Toques Que Se Sienten Reales

Los gestos en aplicaciones móviles deben sentirse instantáneos y naturales. El problema con PanResponder de React Native es que procesa eventos en el JavaScript thread, introduciendo latencia perceptible. Gesture Handler v2 procesa toques directamente en el thread nativo y solo notifica a JavaScript cuando es absolutamente necesario.

Pan Gesture: La Base del Arrastre

Un gesto de pan (arrastre) parece simple pero involucra varios estados: inicio del toque, movimiento continuo, y liberación con potencial inercia. Gesture Handler maneja toda esta complejidad de forma elegante.

1import { GestureDetector, Gesture } from 'react-native-gesture-handler'; 2import Animated, { 3 useSharedValue, 4 useAnimatedStyle, 5 withDecay, 6} from 'react-native-reanimated'; 7 8const DraggableCard: React.FC = () => { 9 const translateX = useSharedValue(0); 10 const translateY = useSharedValue(0); 11 const context = useSharedValue({ x: 0, y: 0 }); 12 13 const panGesture = Gesture.Pan() 14 .onStart(() => { 15 context.value = { 16 x: translateX.value, 17 y: translateY.value, 18 }; 19 }) 20 .onUpdate((event) => { 21 translateX.value = event.translationX + context.value.x; 22 translateY.value = event.translationY + context.value.y; 23 }) 24 .onEnd((event) => { 25 translateX.value = withDecay({ 26 velocity: event.velocityX, 27 clamp: [-200, 200], 28 }); 29 translateY.value = withDecay({ 30 velocity: event.velocityY, 31 clamp: [-400, 400], 32 }); 33 }); 34 35 const animatedStyle = useAnimatedStyle(() => ({ 36 transform: [ 37 { translateX: translateX.value }, 38 { translateY: translateY.value }, 39 ], 40 })); 41 42 return ( 43 <GestureDetector gesture={panGesture}> 44 <Animated.View style={[styles.card, animatedStyle]}> 45 <Text>Drag me!</Text> 46 </Animated.View> 47 </GestureDetector> 48 ); 49};

El patrón clave aquí es el uso de context para guardar la posición donde comenzó el arrastre. Sin esto, cada vez que sueltas y vuelves a arrastrar, el elemento saltaría a su posición original. El onUpdate calcula la nueva posición sumando cuánto se movió el dedo (event.translationX) a donde estaba el elemento cuando empezó el gesto (context.value.x).

La física de inercia viene con withDecay en el onEnd. Toma la velocidad del dedo al soltar y continúa el movimiento desacelerando naturalmente, exactamente como en apps nativas. El clamp previene que el elemento se salga de límites definidos, en este caso manteniéndolo dentro de un rango de -200 a 200 píxeles horizontalmente y -400 a 400 verticalmente.

Swipe Actions: El Patrón de Email

Las swipe actions son ese patrón donde deslizas un elemento de lista para revelar opciones como eliminar o archivar. Es omnipresente en apps de email, to-do lists y redes sociales porque es extremadamente eficiente para acciones rápidas.

1import { runOnJS } from 'react-native-reanimated'; 2 3interface SwipeableRowProps { 4 item: { id: string; title: string }; 5 onDelete: (id: string) => void; 6 onArchive: (id: string) => void; 7} 8 9const SwipeableRow: React.FC<SwipeableRowProps> = ({ 10 item, 11 onDelete, 12 onArchive 13}) => { 14 const translateX = useSharedValue(0); 15 const [isRemoving, setIsRemoving] = useState(false); 16 17 const panGesture = Gesture.Pan() 18 .activeOffsetX([-10, 10]) 19 .onUpdate((event) => { 20 translateX.value = Math.max(-150, Math.min(150, event.translationX)); 21 }) 22 .onEnd(() => { 23 const shouldDelete = translateX.value < -100; 24 const shouldArchive = translateX.value > 100; 25 26 if (shouldDelete) { 27 translateX.value = withSpring(-300, {}, () => { 28 runOnJS(setIsRemoving)(true); 29 runOnJS(onDelete)(item.id); 30 }); 31 } else if (shouldArchive) { 32 translateX.value = withSpring(300, {}, () => { 33 runOnJS(onArchive)(item.id); 34 }); 35 } else { 36 translateX.value = withSpring(0); 37 } 38 }); 39 40 const animatedStyle = useAnimatedStyle(() => ({ 41 transform: [{ translateX: translateX.value }], 42 })); 43 44 if (isRemoving) return null; 45 46 return ( 47 <View style={styles.container}> 48 <GestureDetector gesture={panGesture}> 49 <Animated.View style={[styles.row, animatedStyle]}> 50 <Text>{item.title}</Text> 51 </Animated.View> 52 </GestureDetector> 53 </View> 54 ); 55};

El .activeOffsetX([-10, 10]) es un detalle importante: previene que el swipe se active con movimientos verticales accidentales. El gesto solo se "activa" cuando el usuario se mueve al menos 10 píxeles horizontalmente. Esto permite que el scroll vertical de la lista funcione sin conflicto.

El Math.max(-150, Math.min(150, ...)) limita el swipe para que no puedas arrastrar infinitamente. Y la lógica en onEnd determina la intención del usuario: si pasó el umbral de -100 píxeles, queremos eliminar; si pasó +100, archivar; de lo contrario, volver a la posición original.

El runOnJS es crucial porque estamos en un worklet (UI thread) pero necesitamos ejecutar funciones de JavaScript como setIsRemoving y onDelete. Este helper marca explícitamente que esas llamadas deben hacerse en el JavaScript thread.

Long Press: Menús Contextuales

Los long press son perfectos para revelar acciones secundarias sin saturar la UI con botones. El patrón común es reducir la escala y opacidad del elemento para dar feedback visual de que el gesto fue reconocido.

1interface LongPressCardProps { 2 item: { id: string; title: string }; 3 onLongPress: (item: any) => void; 4} 5 6const LongPressCard: React.FC<LongPressCardProps> = ({ item, onLongPress }) => { 7 const scale = useSharedValue(1); 8 const opacity = useSharedValue(1); 9 10 const longPressGesture = Gesture.LongPress() 11 .minDuration(400) 12 .onStart(() => { 13 scale.value = withTiming(0.95, { duration: 200 }); 14 opacity.value = withTiming(0.7, { duration: 200 }); 15 }) 16 .onEnd(() => { 17 scale.value = withSpring(1); 18 opacity.value = withSpring(1); 19 runOnJS(onLongPress)(item); 20 }) 21 .onFinalize(() => { 22 scale.value = withSpring(1); 23 opacity.value = withSpring(1); 24 }); 25 26 const animatedStyle = useAnimatedStyle(() => ({ 27 transform: [{ scale: scale.value }], 28 opacity: opacity.value, 29 })); 30 31 return ( 32 <GestureDetector gesture={longPressGesture}> 33 <Animated.View style={[styles.card, animatedStyle]}> 34 <Text>{item.title}</Text> 35 </Animated.View> 36 </GestureDetector> 37 ); 38};

El .minDuration(400) define que el usuario debe mantener presionado por al menos 400ms antes que se active el gesto. Esto previene activaciones accidentales. El onStart se ejecuta cuando el long press es reconocido (después de los 400ms), reduciendo visualmente el elemento para dar feedback. El onEnd se ejecuta cuando el usuario suelta, momento en el cual restauramos la apariencia y ejecutamos la acción. El onFinalize es un safety net que se ejecuta sin importar cómo terminó el gesto (cancelación, éxito, error), asegurando que el elemento siempre vuelva a su estado normal.

Composición de Gestos: Pinch + Pan Simultáneos

Una de las capacidades más poderosas de Gesture Handler v2 es componer múltiples gestos. Puedes definir si se ejecutan simultáneamente, exclusivamente (solo uno a la vez), o en secuencia.

1const ZoomablePannable: React.FC<{ children: React.ReactNode }> = ({ 2 children 3}) => { 4 const scale = useSharedValue(1); 5 const translateX = useSharedValue(0); 6 const translateY = useSharedValue(0); 7 const savedScale = useSharedValue(1); 8 const savedTranslate = useSharedValue({ x: 0, y: 0 }); 9 10 const pinchGesture = Gesture.Pinch() 11 .onUpdate((event) => { 12 scale.value = savedScale.value * event.scale; 13 }) 14 .onEnd(() => { 15 savedScale.value = scale.value; 16 }); 17 18 const panGesture = Gesture.Pan() 19 .onUpdate((event) => { 20 translateX.value = savedTranslate.value.x + event.translationX; 21 translateY.value = savedTranslate.value.y + event.translationY; 22 }) 23 .onEnd(() => { 24 savedTranslate.value = { 25 x: translateX.value, 26 y: translateY.value, 27 }; 28 }); 29 30 const composed = Gesture.Simultaneous(pinchGesture, panGesture); 31 32 const animatedStyle = useAnimatedStyle(() => ({ 33 transform: [ 34 { translateX: translateX.value }, 35 { translateY: translateY.value }, 36 { scale: scale.value }, 37 ], 38 })); 39 40 return ( 41 <GestureDetector gesture={composed}> 42 <Animated.View style={animatedStyle}> 43 {children} 44 </Animated.View> 45 </GestureDetector> 46 ); 47};

El Gesture.Simultaneous permite que ambos gestos ocurran al mismo tiempo. Puedes hacer pinch para zoom mientras arrastras la imagen, exactamente como en la app de Fotos de iOS. Si usaras Gesture.Exclusive, solo uno de los gestos podría estar activo a la vez, obligando al usuario a terminar uno antes de empezar el otro.

El patrón de guardar valores en savedScale y savedTranslate asegura que cada gesto continúe desde donde el anterior terminó, en lugar de reiniciar a valores por default.

Integrando Todo: Un Feed Moderno

Ahora que entendemos cada pieza individualmente, veamos cómo se integran en un componente real de producción. Un feed animado con swipe actions y double-tap para like combina FlashList para performance, Reanimated para animaciones fluidas, y Gesture Handler para interacciones naturales.

1import { FlashList } from "@shopify/flash-list"; 2import { withSequence } from 'react-native-reanimated'; 3 4interface Post { 5 id: string; 6 title: string; 7 content: string; 8 imageUrl?: string; 9} 10 11const AnimatedFeed: React.FC = () => { 12 const [posts, setPosts] = useState<Post[]>([]); 13 14 const renderItem = useCallback( 15 ({ item, index }: { item: Post; index: number }) => ( 16 <Animated.View 17 entering={FadeIn.delay(index * 50)} 18 exiting={FadeOut} 19 > 20 <SwipeablePostCard 21 post={item} 22 onDelete={(id) => setPosts(p => p.filter(post => post.id !== id))} 23 onLike={(id) => console.log('Liked:', id)} 24 /> 25 </Animated.View> 26 ), 27 [] 28 ); 29 30 return ( 31 <FlashList 32 data={posts} 33 renderItem={renderItem} 34 estimatedItemSize={200} 35 keyExtractor={item => item.id} 36 /> 37 ); 38};

Cada post entra con un fade escalonado basado en su índice, creando ese efecto de cascada que se ve en apps premium. Cuando se elimina, sale con fade out. FlashList se encarga de reciclar eficientemente los componentes mientras el usuario hace scroll.

1interface SwipeablePostCardProps { 2 post: Post; 3 onDelete: (id: string) => void; 4 onLike: (id: string) => void; 5} 6 7const SwipeablePostCard: React.FC<SwipeablePostCardProps> = ({ 8 post, 9 onDelete, 10 onLike, 11}) => { 12 const translateX = useSharedValue(0); 13 const scale = useSharedValue(1); 14 15 const swipeGesture = Gesture.Pan() 16 .activeOffsetX([-10, 10]) 17 .onUpdate((event) => { 18 translateX.value = Math.min(0, event.translationX); 19 }) 20 .onEnd(() => { 21 if (translateX.value < -100) { 22 translateX.value = withSpring(-400, {}, () => { 23 runOnJS(onDelete)(post.id); 24 }); 25 } else { 26 translateX.value = withSpring(0); 27 } 28 }); 29 30 const doubleTap = Gesture.Tap() 31 .numberOfTaps(2) 32 .onEnd(() => { 33 scale.value = withSequence( 34 withSpring(1.2), 35 withSpring(1) 36 ); 37 runOnJS(onLike)(post.id); 38 }); 39 40 const composed = Gesture.Exclusive(swipeGesture, doubleTap); 41 42 const animatedStyle = useAnimatedStyle(() => ({ 43 transform: [ 44 { translateX: translateX.value }, 45 { scale: scale.value }, 46 ], 47 })); 48 49 return ( 50 <GestureDetector gesture={composed}> 51 <Animated.View style={[styles.postCard, animatedStyle]}> 52 <PostContent post={post} /> 53 </Animated.View> 54 </GestureDetector> 55 ); 56};

Aquí vemos la composición de gestos en acción. El Gesture.Exclusive asegura que el swipe y el double-tap no entren en conflicto. Si detecta un double-tap, el swipe no se activa, y viceversa. El withSequence en el double-tap crea ese efecto satisfactorio de "pop" donde el elemento crece brevemente y luego vuelve a su tamaño, dando feedback inmediato de la acción.

La restricción Math.min(0, event.translationX) asegura que solo puedas hacer swipe hacia la izquierda (valores negativos), previniendo swipes hacia la derecha que podrían confundir al usuario. Cuando el swipe supera los -100 píxeles, interpretamos que el usuario quiere eliminar el post y lo animamos completamente fuera de pantalla antes de ejecutar la eliminación real.

Optimizaciones Finales y Best Practices

Ahora que dominas las herramientas, hablemos de los errores comunes que pueden arruinar la performance incluso con las mejores librerías.

Memoización en Listas

El renderItem de FlatList o FlashList se ejecuta cada vez que un item entra en pantalla. Si no usas useCallback, React recrea la función en cada render del componente padre, causando que todos los items se re-rendericen innecesariamente.

1// ❌ Mal: renderItem se recrea en cada render 2const BadList: React.FC<{ items: Item[] }> = ({ items }) => { 3 return ( 4 <FlashList 5 data={items} 6 renderItem={({ item }) => <ItemCard item={item} />} 7 estimatedItemSize={100} 8 /> 9 ); 10}; 11 12// ✅ Bien: renderItem es estable 13const GoodList: React.FC<{ items: Item[] }> = ({ items }) => { 14 const renderItem = useCallback( 15 ({ item }: { item: Item }) => <ItemCard item={item} />, 16 [] 17 ); 18 19 return ( 20 <FlashList 21 data={items} 22 renderItem={renderItem} 23 estimatedItemSize={100} 24 /> 25 ); 26};

Lo mismo aplica para keyExtractor, getItemLayout, y cualquier otra función que pases a la lista. La memoización con useCallback asegura que React puede hacer optimizaciones de reconciliación correctamente.

Keys Estables en Listas Animadas

Cuando usas layout animations con listas, las keys son absolutamente críticas. React usa las keys para determinar qué elementos son los mismos entre renders. Si tus keys cambian o no son únicas, las animaciones se romperán o los elementos harán cosas extrañas.

1// ❌ Mal: keys basadas en índice 2{items.map((item, index) => ( 3 <Animated.View key={index} entering={FadeIn}> 4 <ItemCard item={item} /> 5 </Animated.View> 6))} 7 8// ✅ Bien: keys únicas y estables 9{items.map((item) => ( 10 <Animated.View key={item.id} entering={FadeIn}> 11 <ItemCard item={item} /> 12 </Animated.View> 13))}

El problema con índices como keys es que cuando eliminas un elemento, todos los índices posteriores cambian. React piensa que son elementos completamente diferentes y destruye/recrea componentes en lugar de animarlos correctamente.

Limpieza de Listeners y Valores Compartidos

Los shared values y los gestos mantienen referencias nativas que necesitan limpiarse. En componentes complejos, especialmente aquellos que se montan y desmontan frecuentemente, olvidar la limpieza puede causar memory leaks.

1const ProperCleanup: React.FC = () => { 2 const translateX = useSharedValue(0); 3 4 useEffect(() => { 5 return () => { 6 // Cancelar animaciones en progreso 7 cancelAnimation(translateX); 8 }; 9 }, [translateX]); 10 11 // Resto del componente... 12};

Aunque Reanimated generalmente maneja la limpieza automáticamente, en casos donde controlas manualmente el ciclo de vida de animaciones (como con cancelAnimation o runOnUI), la limpieza explícita previene problemas sutiles.

Conclusión

Crear interfaces móviles que se sientan verdaderamente nativas en React Native requiere entender profundamente cómo funcionan los threads y usar las herramientas correctas para cada problema. FlatList y FlashList resuelven el renderizado eficiente de listas masivas eliminando mediciones innecesarias y reciclando vistas agresivamente. Reanimated 3 lleva las animaciones al UI thread nativo donde pueden correr a 60 FPS independientemente de lo que esté haciendo JavaScript. Gesture Handler v2 procesa toques con latencia casi nula, creando interacciones que se sienten instantáneas.

La clave está en ser intencional con cada optimización. No todas las listas necesitan FlashList ni todas las animaciones necesitan Reanimated. Pero cuando tus usuarios empiezan a notar lag o cuando tu app se siente menos fluida que las alternativas nativas, estas herramientas con TypeScript te dan el control necesario para competir con cualquier experiencia nativa pura. El resultado es una aplicación que no solo funciona bien, sino que se siente premium desde el primer toque.