Skip to main content

Module 9: Animations and Gestures

Gesture Handler

Build native-quality touch interactions with React Native Gesture Handler

🎯 Learning Objectives

  • Understand why Gesture Handler is superior to built-in touch handling
  • Implement tap, pan, pinch, and rotation gestures
  • Use the modern Gesture API for cleaner gesture code
  • Combine gestures for complex interactions
  • Integrate gestures with Reanimated for smooth animations

Why Gesture Handler?

React Native's built-in touch system (TouchableOpacity, PanResponder) runs on the JavaScript thread, which can lead to dropped frames and laggy interactions. React Native Gesture Handler runs gestures on the native thread, providing smooth 60fps interactions even when the JS thread is busy.

Built-in vs Gesture Handler

flowchart LR
    subgraph Builtin["Built-in Touch System"]
        A[Touch Event] -->|Bridge| B[JS Thread]
        B -->|Bridge| C[Native Response]
        D[⚠️ Lag when JS busy]
    end
    
    subgraph GH["Gesture Handler"]
        E[Touch Event] --> F[Native Thread]
        F --> G[Native Response]
        F -.->|Optional| H[JS Callback]
        I[✅ Always smooth]
    end
    
    style Builtin fill:#fff3e0
    style GH fill:#e8f5e9

Key Benefits

Feature Built-in Gesture Handler
Thread JavaScript Native (UI)
Performance Can drop frames Consistent 60fps
Gesture types Basic tap, pan Tap, pan, pinch, rotate, fling, long press
Gesture composition Manual, complex Built-in simultaneous, exclusive
Reanimated integration Limited Seamless

💡 When to Use Gesture Handler

  • Draggable elements (cards, sliders, drawers)
  • Swipe-to-delete or swipe actions
  • Pinch-to-zoom on images or maps
  • Double-tap to like/zoom
  • Any gesture that needs to feel "native"

Installation and Setup

Installing with Expo

# Install Gesture Handler
npx expo install react-native-gesture-handler

# Usually installed alongside Reanimated
npx expo install react-native-reanimated react-native-gesture-handler

App Setup

Wrap your app with GestureHandlerRootView:

// App.tsx
import { GestureHandlerRootView } from 'react-native-gesture-handler';

export default function App() {
  return (
    <GestureHandlerRootView style={{ flex: 1 }}>
      <NavigationContainer>
        {/* Your app content */}
      </NavigationContainer>
    </GestureHandlerRootView>
  );
}

⚠️ Important

GestureHandlerRootView must be at the root of your app. Without it, gestures won't work. It replaces the need for a top-level View.

Import Pattern

// Modern Gesture API (recommended)
import { Gesture, GestureDetector } from 'react-native-gesture-handler';

// For Reanimated integration
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withSpring,
} from 'react-native-reanimated';

The Gesture API

Gesture Handler v2 introduced the modern Gesture API, which is more declarative and easier to use than the older handler-based API. This is the recommended approach for new projects.

API Overview

flowchart TD
    subgraph Creation["1. Create Gesture"]
        A[Gesture.Pan]
        B[Gesture.Tap]
        C[Gesture.Pinch]
        D[Gesture.Rotation]
        E[Gesture.LongPress]
        F[Gesture.Fling]
    end
    
    subgraph Config["2. Configure"]
        G[.onStart]
        H[.onUpdate]
        I[.onEnd]
        J[.minDistance]
        K[.numberOfTaps]
    end
    
    subgraph Apply["3. Apply"]
        L[GestureDetector]
        M[Animated.View]
    end
    
    Creation --> Config --> Apply
    
    style Creation fill:#e3f2fd
    style Config fill:#fff3e0
    style Apply fill:#e8f5e9

Basic Pattern

import React from 'react';
import { StyleSheet, View } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
} from 'react-native-reanimated';

function GestureExample() {
  // 1. Create shared values for animation
  const translateX = useSharedValue(0);
  const translateY = useSharedValue(0);
  
  // 2. Create the gesture
  const panGesture = Gesture.Pan()
    .onUpdate((event) => {
      translateX.value = event.translationX;
      translateY.value = event.translationY;
    })
    .onEnd(() => {
      translateX.value = withSpring(0);
      translateY.value = withSpring(0);
    });
  
  // 3. Create animated style
  const animatedStyle = useAnimatedStyle(() => ({
    transform: [
      { translateX: translateX.value },
      { translateY: translateY.value },
    ],
  }));
  
  // 4. Apply with GestureDetector
  return (
    <View style={styles.container}>
      <GestureDetector gesture={panGesture}>
        <Animated.View style={[styles.box, animatedStyle]} />
      </GestureDetector>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
  box: {
    width: 100,
    height: 100,
    backgroundColor: '#007AFF',
    borderRadius: 10,
  },
});

Gesture Lifecycle

const gesture = Gesture.Pan()
  // Called when gesture is recognized and starts
  .onStart((event) => {
    console.log('Gesture started at:', event.absoluteX, event.absoluteY);
  })
  
  // Called continuously as the gesture updates
  .onUpdate((event) => {
    console.log('Translation:', event.translationX, event.translationY);
    console.log('Velocity:', event.velocityX, event.velocityY);
  })
  
  // Called when gesture ends (finger lifted)
  .onEnd((event) => {
    console.log('Gesture ended with velocity:', event.velocityX);
  })
  
  // Called when gesture is finalized (after any animations)
  .onFinalize((event, success) => {
    console.log('Gesture finalized, success:', success);
  });

// Gesture states:
// UNDETERMINED - Initial state
// BEGAN - Gesture recognized
// ACTIVE - Gesture in progress
// END - Finger lifted
// CANCELLED - Gesture cancelled
// FAILED - Gesture failed to recognize

Tap Gestures

Tap gestures handle single taps, double taps, and multi-tap interactions.

Single Tap

import React from 'react';
import { StyleSheet, Text } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withSequence,
  withTiming,
} from 'react-native-reanimated';

function TapExample() {
  const scale = useSharedValue(1);
  
  const tapGesture = Gesture.Tap()
    .onStart(() => {
      // Immediate feedback
      scale.value = withTiming(0.95, { duration: 50 });
    })
    .onEnd(() => {
      // Bounce back
      scale.value = withSequence(
        withTiming(1.05, { duration: 100 }),
        withTiming(1, { duration: 100 })
      );
    });
  
  const animatedStyle = useAnimatedStyle(() => ({
    transform: [{ scale: scale.value }],
  }));
  
  return (
    <GestureDetector gesture={tapGesture}>
      <Animated.View style={[styles.button, animatedStyle]}>
        <Text style={styles.buttonText}>Tap Me</Text>
      </Animated.View>
    </GestureDetector>
  );
}

const styles = StyleSheet.create({
  button: {
    backgroundColor: '#007AFF',
    paddingVertical: 16,
    paddingHorizontal: 32,
    borderRadius: 12,
  },
  buttonText: {
    color: 'white',
    fontSize: 18,
    fontWeight: '600',
  },
});

Double Tap

import { Gesture, GestureDetector } from 'react-native-gesture-handler';

function DoubleTapExample() {
  const scale = useSharedValue(1);
  const [liked, setLiked] = useState(false);
  
  const doubleTapGesture = Gesture.Tap()
    .numberOfTaps(2)
    .onEnd(() => {
      // Like animation
      scale.value = withSequence(
        withSpring(1.4, { damping: 4 }),
        withSpring(1, { damping: 6 })
      );
      runOnJS(setLiked)(true);
    });
  
  const animatedStyle = useAnimatedStyle(() => ({
    transform: [{ scale: scale.value }],
  }));
  
  return (
    <GestureDetector gesture={doubleTapGesture}>
      <Animated.View style={styles.container}>
        <Image source={{ uri: imageUrl }} style={styles.image} />
        {liked && (
          <Animated.Text style={[styles.heart, animatedStyle]}>
            ❤️
          </Animated.Text>
        )}
      </Animated.View>
    </GestureDetector>
  );
}

Single and Double Tap Combined

function CombinedTapExample() {
  const opacity = useSharedValue(1);
  const scale = useSharedValue(1);
  
  // Double tap - must be defined first (more specific)
  const doubleTap = Gesture.Tap()
    .numberOfTaps(2)
    .onEnd(() => {
      scale.value = withSequence(
        withSpring(1.2),
        withSpring(1)
      );
    });
  
  // Single tap - requires double tap to fail first
  const singleTap = Gesture.Tap()
    .requireExternalGestureToFail(doubleTap)
    .onEnd(() => {
      opacity.value = withSequence(
        withTiming(0.5, { duration: 100 }),
        withTiming(1, { duration: 100 })
      );
    });
  
  // Compose gestures - order matters!
  const composedGesture = Gesture.Exclusive(doubleTap, singleTap);
  
  const animatedStyle = useAnimatedStyle(() => ({
    opacity: opacity.value,
    transform: [{ scale: scale.value }],
  }));
  
  return (
    <GestureDetector gesture={composedGesture}>
      <Animated.View style={[styles.box, animatedStyle]}>
        <Text>Single tap: flash</Text>
        <Text>Double tap: bounce</Text>
      </Animated.View>
    </GestureDetector>
  );
}

Long Press

function LongPressExample() {
  const scale = useSharedValue(1);
  const backgroundColor = useSharedValue('#007AFF');
  
  const longPressGesture = Gesture.LongPress()
    .minDuration(500) // 500ms to trigger
    .onStart(() => {
      // Visual feedback that long press started
      scale.value = withTiming(0.95);
      backgroundColor.value = '#FF9500';
    })
    .onEnd((event, success) => {
      scale.value = withSpring(1);
      backgroundColor.value = '#007AFF';
      
      if (success) {
        // Long press completed
        runOnJS(showContextMenu)();
      }
    });
  
  const animatedStyle = useAnimatedStyle(() => ({
    transform: [{ scale: scale.value }],
    backgroundColor: backgroundColor.value,
  }));
  
  return (
    <GestureDetector gesture={longPressGesture}>
      <Animated.View style={[styles.item, animatedStyle]}>
        <Text>Long press for options</Text>
      </Animated.View>
    </GestureDetector>
  );
}

Pan Gestures

Pan gestures track finger movement across the screen. They're essential for draggable elements, sliders, and swipe interactions.

Basic Pan (Draggable Box)

import React from 'react';
import { StyleSheet, View, Dimensions } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withSpring,
} from 'react-native-reanimated';

const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');

function DraggableBox() {
  const translateX = useSharedValue(0);
  const translateY = useSharedValue(0);
  const context = useSharedValue({ x: 0, y: 0 });
  
  const panGesture = Gesture.Pan()
    .onStart(() => {
      // Store the starting position
      context.value = {
        x: translateX.value,
        y: translateY.value,
      };
    })
    .onUpdate((event) => {
      // Add translation to starting position
      translateX.value = context.value.x + event.translationX;
      translateY.value = context.value.y + event.translationY;
    })
    .onEnd(() => {
      // Optional: snap back to origin
      // translateX.value = withSpring(0);
      // translateY.value = withSpring(0);
    });
  
  const animatedStyle = useAnimatedStyle(() => ({
    transform: [
      { translateX: translateX.value },
      { translateY: translateY.value },
    ],
  }));
  
  return (
    <View style={styles.container}>
      <GestureDetector gesture={panGesture}>
        <Animated.View style={[styles.box, animatedStyle]} />
      </GestureDetector>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
  box: {
    width: 100,
    height: 100,
    backgroundColor: '#007AFF',
    borderRadius: 10,
  },
});

Pan with Boundaries

function BoundedDraggable() {
  const translateX = useSharedValue(0);
  const translateY = useSharedValue(0);
  const context = useSharedValue({ x: 0, y: 0 });
  
  const BOX_SIZE = 100;
  const BOUNDARY_X = (SCREEN_WIDTH - BOX_SIZE) / 2;
  const BOUNDARY_Y = (SCREEN_HEIGHT - BOX_SIZE) / 2;
  
  const panGesture = Gesture.Pan()
    .onStart(() => {
      context.value = {
        x: translateX.value,
        y: translateY.value,
      };
    })
    .onUpdate((event) => {
      // Clamp to boundaries
      const newX = context.value.x + event.translationX;
      const newY = context.value.y + event.translationY;
      
      translateX.value = Math.max(-BOUNDARY_X, Math.min(BOUNDARY_X, newX));
      translateY.value = Math.max(-BOUNDARY_Y, Math.min(BOUNDARY_Y, newY));
    });
  
  const animatedStyle = useAnimatedStyle(() => ({
    transform: [
      { translateX: translateX.value },
      { translateY: translateY.value },
    ],
  }));
  
  return (
    <View style={styles.container}>
      <GestureDetector gesture={panGesture}>
        <Animated.View style={[styles.box, animatedStyle]} />
      </GestureDetector>
    </View>
  );
}

Horizontal Slider

import React from 'react';
import { StyleSheet, View, Text } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  useDerivedValue,
  interpolate,
  Extrapolation,
  runOnJS,
} from 'react-native-reanimated';

interface SliderProps {
  min?: number;
  max?: number;
  initialValue?: number;
  onValueChange?: (value: number) => void;
}

function Slider({
  min = 0,
  max = 100,
  initialValue = 50,
  onValueChange,
}: SliderProps) {
  const TRACK_WIDTH = 280;
  const THUMB_SIZE = 28;
  const MAX_TRANSLATE = TRACK_WIDTH - THUMB_SIZE;
  
  const initialTranslate = ((initialValue - min) / (max - min)) * MAX_TRANSLATE;
  const translateX = useSharedValue(initialTranslate);
  const context = useSharedValue(0);
  
  const value = useDerivedValue(() => {
    const normalized = translateX.value / MAX_TRANSLATE;
    return Math.round(min + normalized * (max - min));
  });
  
  const panGesture = Gesture.Pan()
    .onStart(() => {
      context.value = translateX.value;
    })
    .onUpdate((event) => {
      const newValue = context.value + event.translationX;
      translateX.value = Math.max(0, Math.min(MAX_TRANSLATE, newValue));
    })
    .onEnd(() => {
      if (onValueChange) {
        runOnJS(onValueChange)(value.value);
      }
    });
  
  const thumbStyle = useAnimatedStyle(() => ({
    transform: [{ translateX: translateX.value }],
  }));
  
  const fillStyle = useAnimatedStyle(() => ({
    width: translateX.value + THUMB_SIZE / 2,
  }));
  
  const valueStyle = useAnimatedStyle(() => ({
    // Position value label above thumb
    transform: [{ translateX: translateX.value }],
  }));
  
  return (
    <View style={styles.sliderContainer}>
      <View style={[styles.track, { width: TRACK_WIDTH }]}>
        <Animated.View style={[styles.fill, fillStyle]} />
        
        <GestureDetector gesture={panGesture}>
          <Animated.View style={[styles.thumb, thumbStyle]} />
        </GestureDetector>
      </View>
      
      <Animated.View style={[styles.valueLabel, valueStyle]}>
        <Text style={styles.valueText}>{value.value}</Text>
      </Animated.View>
    </View>
  );
}

const styles = StyleSheet.create({
  sliderContainer: {
    alignItems: 'flex-start',
    paddingTop: 30,
  },
  track: {
    height: 6,
    backgroundColor: '#E0E0E0',
    borderRadius: 3,
  },
  fill: {
    position: 'absolute',
    height: 6,
    backgroundColor: '#007AFF',
    borderRadius: 3,
  },
  thumb: {
    position: 'absolute',
    top: -11,
    width: 28,
    height: 28,
    backgroundColor: '#FFFFFF',
    borderRadius: 14,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.2,
    shadowRadius: 4,
    elevation: 4,
  },
  valueLabel: {
    position: 'absolute',
    top: 0,
    width: 28,
    alignItems: 'center',
  },
  valueText: {
    fontSize: 14,
    fontWeight: '600',
    color: '#007AFF',
  },
});

Swipe to Delete

import React, { useState } from 'react';
import { StyleSheet, View, Text, Dimensions } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withSpring,
  withTiming,
  runOnJS,
  interpolate,
  Extrapolation,
} from 'react-native-reanimated';

const { width: SCREEN_WIDTH } = Dimensions.get('window');
const DELETE_THRESHOLD = -SCREEN_WIDTH * 0.3;

interface SwipeableItemProps {
  id: string;
  title: string;
  onDelete: (id: string) => void;
}

function SwipeableItem({ id, title, onDelete }: SwipeableItemProps) {
  const translateX = useSharedValue(0);
  const itemHeight = useSharedValue(70);
  const opacity = useSharedValue(1);
  
  const handleDelete = () => {
    onDelete(id);
  };
  
  const panGesture = Gesture.Pan()
    .activeOffsetX([-10, 10]) // Require 10px horizontal movement to activate
    .onUpdate((event) => {
      // Only allow left swipe
      translateX.value = Math.min(0, event.translationX);
    })
    .onEnd((event) => {
      const shouldDelete = translateX.value < DELETE_THRESHOLD ||
                          event.velocityX < -500;
      
      if (shouldDelete) {
        // Animate out and delete
        translateX.value = withTiming(-SCREEN_WIDTH, { duration: 200 });
        itemHeight.value = withTiming(0, { duration: 200 });
        opacity.value = withTiming(0, { duration: 200 }, () => {
          runOnJS(handleDelete)();
        });
      } else {
        // Snap back
        translateX.value = withSpring(0);
      }
    });
  
  const itemStyle = useAnimatedStyle(() => ({
    transform: [{ translateX: translateX.value }],
  }));
  
  const containerStyle = useAnimatedStyle(() => ({
    height: itemHeight.value,
    opacity: opacity.value,
  }));
  
  const deleteButtonStyle = useAnimatedStyle(() => {
    const buttonOpacity = interpolate(
      translateX.value,
      [DELETE_THRESHOLD, 0],
      [1, 0],
      Extrapolation.CLAMP
    );
    
    return {
      opacity: buttonOpacity,
    };
  });
  
  return (
    <Animated.View style={[styles.itemContainer, containerStyle]}>
      {/* Delete background */}
      <Animated.View style={[styles.deleteBackground, deleteButtonStyle]}>
        <Text style={styles.deleteText}>🗑️ Delete</Text>
      </Animated.View>
      
      {/* Swipeable content */}
      <GestureDetector gesture={panGesture}>
        <Animated.View style={[styles.item, itemStyle]}>
          <Text style={styles.itemTitle}>{title}</Text>
        </Animated.View>
      </GestureDetector>
    </Animated.View>
  );
}

const styles = StyleSheet.create({
  itemContainer: {
    position: 'relative',
    marginBottom: 8,
  },
  deleteBackground: {
    position: 'absolute',
    right: 0,
    top: 0,
    bottom: 0,
    width: '100%',
    backgroundColor: '#FF3B30',
    justifyContent: 'center',
    alignItems: 'flex-end',
    paddingRight: 20,
    borderRadius: 12,
  },
  deleteText: {
    color: 'white',
    fontSize: 16,
    fontWeight: '600',
  },
  item: {
    backgroundColor: 'white',
    padding: 20,
    borderRadius: 12,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 1 },
    shadowOpacity: 0.1,
    shadowRadius: 2,
    elevation: 2,
  },
  itemTitle: {
    fontSize: 16,
  },
});

Pan Configuration Options

const configuredPan = Gesture.Pan()
  // Minimum distance before gesture activates
  .minDistance(10)
  
  // Minimum velocity to recognize
  .minVelocity(100)
  
  // Active offset - gesture activates after moving this far
  .activeOffsetX([-20, 20])  // Horizontal threshold
  .activeOffsetY([-20, 20])  // Vertical threshold
  
  // Fail offset - gesture fails if movement exceeds this
  .failOffsetX([-50, 50])  // Fail if horizontal > 50 before vertical
  .failOffsetY([-50, 50])  // Fail if vertical > 50 before horizontal
  
  // Number of pointers (fingers) required
  .minPointers(1)
  .maxPointers(1)
  
  // Average touches (for multi-touch)
  .averageTouches(true)
  
  // Enable gesture on specific axis only
  .activeOffsetX([-10, 10])  // Horizontal swipe only
  .failOffsetY([-5, 5])      // Fail if vertical movement
  
  // Callbacks
  .onStart((event) => { })
  .onUpdate((event) => { })
  .onEnd((event) => { })
  .onFinalize((event, success) => { });

Pinch and Rotation

Pinch and rotation gestures enable natural multi-touch interactions for scaling and rotating content.

Pinch to Zoom

import React from 'react';
import { StyleSheet, View, Image, Dimensions } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withSpring,
} from 'react-native-reanimated';

const { width: SCREEN_WIDTH } = Dimensions.get('window');

function PinchToZoom() {
  const scale = useSharedValue(1);
  const savedScale = useSharedValue(1);
  
  const pinchGesture = Gesture.Pinch()
    .onStart(() => {
      savedScale.value = scale.value;
    })
    .onUpdate((event) => {
      scale.value = savedScale.value * event.scale;
    })
    .onEnd(() => {
      // Clamp scale between 0.5 and 3
      if (scale.value < 1) {
        scale.value = withSpring(1);
        savedScale.value = 1;
      } else if (scale.value > 3) {
        scale.value = withSpring(3);
        savedScale.value = 3;
      } else {
        savedScale.value = scale.value;
      }
    });
  
  const animatedStyle = useAnimatedStyle(() => ({
    transform: [{ scale: scale.value }],
  }));
  
  return (
    <View style={styles.container}>
      <GestureDetector gesture={pinchGesture}>
        <Animated.Image
          source={{ uri: 'https://picsum.photos/400/400' }}
          style={[styles.image, animatedStyle]}
        />
      </GestureDetector>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: '#000',
  },
  image: {
    width: SCREEN_WIDTH,
    height: SCREEN_WIDTH,
  },
});

Rotation Gesture

function RotatableElement() {
  const rotation = useSharedValue(0);
  const savedRotation = useSharedValue(0);
  
  const rotationGesture = Gesture.Rotation()
    .onStart(() => {
      savedRotation.value = rotation.value;
    })
    .onUpdate((event) => {
      rotation.value = savedRotation.value + event.rotation;
    })
    .onEnd(() => {
      savedRotation.value = rotation.value;
    });
  
  const animatedStyle = useAnimatedStyle(() => ({
    transform: [
      { rotate: `${rotation.value}rad` },
    ],
  }));
  
  return (
    <View style={styles.container}>
      <GestureDetector gesture={rotationGesture}>
        <Animated.View style={[styles.box, animatedStyle]}>
          <Text style={styles.text}>↻</Text>
        </Animated.View>
      </GestureDetector>
    </View>
  );
}

Combined Pan, Pinch, and Rotation

import React from 'react';
import { StyleSheet, View, Image, Dimensions } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withSpring,
} from 'react-native-reanimated';

function TransformableImage() {
  // Translation
  const translateX = useSharedValue(0);
  const translateY = useSharedValue(0);
  const savedTranslateX = useSharedValue(0);
  const savedTranslateY = useSharedValue(0);
  
  // Scale
  const scale = useSharedValue(1);
  const savedScale = useSharedValue(1);
  
  // Rotation
  const rotation = useSharedValue(0);
  const savedRotation = useSharedValue(0);
  
  // Pan gesture
  const panGesture = Gesture.Pan()
    .onStart(() => {
      savedTranslateX.value = translateX.value;
      savedTranslateY.value = translateY.value;
    })
    .onUpdate((event) => {
      translateX.value = savedTranslateX.value + event.translationX;
      translateY.value = savedTranslateY.value + event.translationY;
    });
  
  // Pinch gesture
  const pinchGesture = Gesture.Pinch()
    .onStart(() => {
      savedScale.value = scale.value;
    })
    .onUpdate((event) => {
      scale.value = savedScale.value * event.scale;
    })
    .onEnd(() => {
      if (scale.value < 0.5) {
        scale.value = withSpring(0.5);
        savedScale.value = 0.5;
      } else if (scale.value > 4) {
        scale.value = withSpring(4);
        savedScale.value = 4;
      } else {
        savedScale.value = scale.value;
      }
    });
  
  // Rotation gesture
  const rotationGesture = Gesture.Rotation()
    .onStart(() => {
      savedRotation.value = rotation.value;
    })
    .onUpdate((event) => {
      rotation.value = savedRotation.value + event.rotation;
    })
    .onEnd(() => {
      savedRotation.value = rotation.value;
    });
  
  // Combine all gestures to run simultaneously
  const composedGesture = Gesture.Simultaneous(
    panGesture,
    pinchGesture,
    rotationGesture
  );
  
  const animatedStyle = useAnimatedStyle(() => ({
    transform: [
      { translateX: translateX.value },
      { translateY: translateY.value },
      { scale: scale.value },
      { rotate: `${rotation.value}rad` },
    ],
  }));
  
  return (
    <View style={styles.container}>
      <GestureDetector gesture={composedGesture}>
        <Animated.Image
          source={{ uri: 'https://picsum.photos/300/300' }}
          style={[styles.image, animatedStyle]}
        />
      </GestureDetector>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: '#f0f0f0',
  },
  image: {
    width: 200,
    height: 200,
    borderRadius: 10,
  },
});

Fling Gesture

import { Gesture, GestureDetector, Directions } from 'react-native-gesture-handler';

function FlingExample() {
  const translateX = useSharedValue(0);
  
  // Fling right
  const flingRightGesture = Gesture.Fling()
    .direction(Directions.RIGHT)
    .onEnd(() => {
      translateX.value = withSpring(100);
    });
  
  // Fling left
  const flingLeftGesture = Gesture.Fling()
    .direction(Directions.LEFT)
    .onEnd(() => {
      translateX.value = withSpring(-100);
    });
  
  // Combine fling gestures
  const flingGesture = Gesture.Exclusive(
    flingRightGesture,
    flingLeftGesture
  );
  
  const animatedStyle = useAnimatedStyle(() => ({
    transform: [{ translateX: translateX.value }],
  }));
  
  return (
    <GestureDetector gesture={flingGesture}>
      <Animated.View style={[styles.box, animatedStyle]} />
    </GestureDetector>
  );
}

Hands-On Exercises

Exercise 1: Tinder-Style Swipe Cards

Create swipeable cards that rotate as they're dragged and fly off screen when swiped far enough.

Requirements:

  • Cards stack on top of each other
  • Dragging rotates the card based on horizontal position
  • Swipe past threshold to dismiss (left = reject, right = like)
  • Next card scales up as current card is being swiped
Show Solution
import React, { useState } from 'react';
import { StyleSheet, View, Text, Dimensions, Image } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withSpring,
  withTiming,
  interpolate,
  Extrapolation,
  runOnJS,
} from 'react-native-reanimated';

const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
const SWIPE_THRESHOLD = SCREEN_WIDTH * 0.3;
const ROTATION_ANGLE = 20;

interface Card {
  id: string;
  name: string;
  image: string;
}

const CARDS: Card[] = [
  { id: '1', name: 'Alex', image: 'https://picsum.photos/300/400?random=1' },
  { id: '2', name: 'Jordan', image: 'https://picsum.photos/300/400?random=2' },
  { id: '3', name: 'Taylor', image: 'https://picsum.photos/300/400?random=3' },
  { id: '4', name: 'Morgan', image: 'https://picsum.photos/300/400?random=4' },
];

function SwipeCard({ 
  card, 
  index, 
  totalCards,
  onSwipe 
}: { 
  card: Card; 
  index: number;
  totalCards: number;
  onSwipe: (direction: 'left' | 'right') => void;
}) {
  const translateX = useSharedValue(0);
  const translateY = useSharedValue(0);
  const cardScale = useSharedValue(1);
  
  const isTopCard = index === totalCards - 1;
  
  const handleSwipe = (direction: 'left' | 'right') => {
    onSwipe(direction);
  };
  
  const panGesture = Gesture.Pan()
    .enabled(isTopCard)
    .onUpdate((event) => {
      translateX.value = event.translationX;
      translateY.value = event.translationY;
    })
    .onEnd((event) => {
      const shouldSwipeRight = translateX.value > SWIPE_THRESHOLD || 
                               event.velocityX > 500;
      const shouldSwipeLeft = translateX.value < -SWIPE_THRESHOLD || 
                              event.velocityX < -500;
      
      if (shouldSwipeRight) {
        translateX.value = withTiming(SCREEN_WIDTH * 1.5, { duration: 300 });
        translateY.value = withTiming(event.translationY * 2, { duration: 300 });
        runOnJS(handleSwipe)('right');
      } else if (shouldSwipeLeft) {
        translateX.value = withTiming(-SCREEN_WIDTH * 1.5, { duration: 300 });
        translateY.value = withTiming(event.translationY * 2, { duration: 300 });
        runOnJS(handleSwipe)('left');
      } else {
        translateX.value = withSpring(0);
        translateY.value = withSpring(0);
      }
    });
  
  const cardStyle = useAnimatedStyle(() => {
    const rotation = interpolate(
      translateX.value,
      [-SCREEN_WIDTH / 2, 0, SCREEN_WIDTH / 2],
      [-ROTATION_ANGLE, 0, ROTATION_ANGLE],
      Extrapolation.CLAMP
    );
    
    // Scale based on position in stack (for non-top cards)
    const stackScale = isTopCard 
      ? 1 
      : interpolate(
          index,
          [totalCards - 2, totalCards - 1],
          [0.95, 1],
          Extrapolation.CLAMP
        );
    
    return {
      transform: [
        { translateX: translateX.value },
        { translateY: translateY.value },
        { rotate: `${rotation}deg` },
        { scale: stackScale },
      ],
      zIndex: index,
    };
  });
  
  const likeStyle = useAnimatedStyle(() => ({
    opacity: interpolate(
      translateX.value,
      [0, SWIPE_THRESHOLD],
      [0, 1],
      Extrapolation.CLAMP
    ),
  }));
  
  const nopeStyle = useAnimatedStyle(() => ({
    opacity: interpolate(
      translateX.value,
      [-SWIPE_THRESHOLD, 0],
      [1, 0],
      Extrapolation.CLAMP
    ),
  }));
  
  return (
    <GestureDetector gesture={panGesture}>
      <Animated.View style={[styles.card, cardStyle]}>
        <Image source={{ uri: card.image }} style={styles.cardImage} />
        
        <View style={styles.cardContent}>
          <Text style={styles.cardName}>{card.name}</Text>
        </View>
        
        {/* Like overlay */}
        <Animated.View style={[styles.overlay, styles.likeOverlay, likeStyle]}>
          <Text style={styles.overlayText}>LIKE</Text>
        </Animated.View>
        
        {/* Nope overlay */}
        <Animated.View style={[styles.overlay, styles.nopeOverlay, nopeStyle]}>
          <Text style={styles.overlayText}>NOPE</Text>
        </Animated.View>
      </Animated.View>
    </GestureDetector>
  );
}

function SwipeCards() {
  const [cards, setCards] = useState(CARDS);
  
  const handleSwipe = (direction: 'left' | 'right') => {
    console.log(`Swiped ${direction}`);
    setTimeout(() => {
      setCards(prev => prev.slice(0, -1));
    }, 300);
  };
  
  return (
    <View style={styles.container}>
      {cards.map((card, index) => (
        <SwipeCard
          key={card.id}
          card={card}
          index={index}
          totalCards={cards.length}
          onSwipe={handleSwipe}
        />
      ))}
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
  card: {
    position: 'absolute',
    width: SCREEN_WIDTH * 0.85,
    height: SCREEN_HEIGHT * 0.65,
    backgroundColor: 'white',
    borderRadius: 20,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 4 },
    shadowOpacity: 0.2,
    shadowRadius: 8,
    elevation: 5,
    overflow: 'hidden',
  },
  cardImage: {
    width: '100%',
    height: '80%',
  },
  cardContent: {
    padding: 16,
  },
  cardName: {
    fontSize: 24,
    fontWeight: 'bold',
  },
  overlay: {
    position: 'absolute',
    top: 50,
    padding: 10,
    borderWidth: 4,
    borderRadius: 8,
  },
  likeOverlay: {
    right: 20,
    borderColor: '#34C759',
    transform: [{ rotate: '20deg' }],
  },
  nopeOverlay: {
    left: 20,
    borderColor: '#FF3B30',
    transform: [{ rotate: '-20deg' }],
  },
  overlayText: {
    fontSize: 32,
    fontWeight: 'bold',
  },
});

Exercise 2: Image Gallery with Zoom

Create an image viewer that supports pinch to zoom and pan when zoomed.

Requirements:

  • Pinch to zoom in/out (max 4x, min 1x)
  • Pan only allowed when zoomed in
  • Double tap to toggle between 1x and 2x zoom
  • Smooth spring animations when snapping back
Show Solution
import React from 'react';
import { StyleSheet, View, Dimensions, Image } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withSpring,
  withTiming,
} from 'react-native-reanimated';

const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');

function ZoomableImage({ uri }: { uri: string }) {
  // Scale
  const scale = useSharedValue(1);
  const savedScale = useSharedValue(1);
  
  // Translation
  const translateX = useSharedValue(0);
  const translateY = useSharedValue(0);
  const savedTranslateX = useSharedValue(0);
  const savedTranslateY = useSharedValue(0);
  
  // Focal point for pinch
  const focalX = useSharedValue(0);
  const focalY = useSharedValue(0);
  
  // Clamp translation to prevent over-panning
  const clampTranslation = () => {
    'worklet';
    const maxTranslateX = ((scale.value - 1) * SCREEN_WIDTH) / 2;
    const maxTranslateY = ((scale.value - 1) * SCREEN_HEIGHT) / 2;
    
    translateX.value = Math.max(
      -maxTranslateX,
      Math.min(maxTranslateX, translateX.value)
    );
    translateY.value = Math.max(
      -maxTranslateY,
      Math.min(maxTranslateY, translateY.value)
    );
  };
  
  // Double tap to toggle zoom
  const doubleTapGesture = Gesture.Tap()
    .numberOfTaps(2)
    .onEnd((event) => {
      if (scale.value > 1.5) {
        // Zoom out
        scale.value = withTiming(1);
        translateX.value = withTiming(0);
        translateY.value = withTiming(0);
        savedScale.value = 1;
      } else {
        // Zoom in to 2x at tap point
        const targetScale = 2;
        scale.value = withTiming(targetScale);
        
        // Calculate translation to center on tap point
        const centerX = SCREEN_WIDTH / 2;
        const centerY = SCREEN_HEIGHT / 2;
        const tapOffsetX = event.x - centerX;
        const tapOffsetY = event.y - centerY;
        
        translateX.value = withTiming(-tapOffsetX);
        translateY.value = withTiming(-tapOffsetY);
        
        savedScale.value = targetScale;
        savedTranslateX.value = -tapOffsetX;
        savedTranslateY.value = -tapOffsetY;
      }
    });
  
  // Pinch to zoom
  const pinchGesture = Gesture.Pinch()
    .onStart((event) => {
      savedScale.value = scale.value;
      focalX.value = event.focalX;
      focalY.value = event.focalY;
    })
    .onUpdate((event) => {
      scale.value = Math.max(1, Math.min(4, savedScale.value * event.scale));
    })
    .onEnd(() => {
      if (scale.value < 1) {
        scale.value = withSpring(1);
        translateX.value = withSpring(0);
        translateY.value = withSpring(0);
        savedScale.value = 1;
      } else {
        savedScale.value = scale.value;
        clampTranslation();
      }
    });
  
  // Pan when zoomed
  const panGesture = Gesture.Pan()
    .onStart(() => {
      savedTranslateX.value = translateX.value;
      savedTranslateY.value = translateY.value;
    })
    .onUpdate((event) => {
      if (scale.value > 1) {
        translateX.value = savedTranslateX.value + event.translationX;
        translateY.value = savedTranslateY.value + event.translationY;
      }
    })
    .onEnd(() => {
      clampTranslation();
      savedTranslateX.value = translateX.value;
      savedTranslateY.value = translateY.value;
    });
  
  // Combine gestures
  const composedGesture = Gesture.Simultaneous(
    Gesture.Exclusive(doubleTapGesture, Gesture.Tap()),
    pinchGesture,
    panGesture
  );
  
  const animatedStyle = useAnimatedStyle(() => ({
    transform: [
      { translateX: translateX.value },
      { translateY: translateY.value },
      { scale: scale.value },
    ],
  }));
  
  return (
    <View style={styles.container}>
      <GestureDetector gesture={composedGesture}>
        <Animated.Image
          source={{ uri }}
          style={[styles.image, animatedStyle]}
          resizeMode="contain"
        />
      </GestureDetector>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#000',
    justifyContent: 'center',
    alignItems: 'center',
  },
  image: {
    width: SCREEN_WIDTH,
    height: SCREEN_HEIGHT,
  },
});

Exercise 3: Draggable Sortable List

Create a list where items can be long-pressed and dragged to reorder.

Requirements:

  • Long press to activate drag mode
  • Visual feedback when item is being dragged
  • Other items animate to make room
  • Items reorder when dropped
Show Solution
import React, { useState } from 'react';
import { StyleSheet, View, Text } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withSpring,
  withTiming,
  runOnJS,
  useAnimatedReaction,
} from 'react-native-reanimated';

const ITEM_HEIGHT = 70;

interface Item {
  id: string;
  title: string;
}

function DraggableItem({
  item,
  index,
  positions,
  itemCount,
  onDragEnd,
}: {
  item: Item;
  index: number;
  positions: Animated.SharedValue<number[]>;
  itemCount: number;
  onDragEnd: (from: number, to: number) => void;
}) {
  const isDragging = useSharedValue(false);
  const translateY = useSharedValue(0);
  const currentPosition = useSharedValue(index);
  
  // Animate to new position when another item is dragged
  useAnimatedReaction(
    () => positions.value[index],
    (newPosition) => {
      if (!isDragging.value) {
        translateY.value = withSpring((newPosition - index) * ITEM_HEIGHT);
      }
    }
  );
  
  const longPressGesture = Gesture.LongPress()
    .minDuration(200)
    .onStart(() => {
      isDragging.value = true;
    });
  
  const panGesture = Gesture.Pan()
    .activateAfterLongPress(200)
    .onUpdate((event) => {
      translateY.value = event.translationY;
      
      // Calculate new position
      const newPosition = Math.round(
        (index * ITEM_HEIGHT + event.translationY) / ITEM_HEIGHT
      );
      const clampedPosition = Math.max(0, Math.min(itemCount - 1, newPosition));
      
      if (clampedPosition !== currentPosition.value) {
        // Update positions array
        const newPositions = [...positions.value];
        
        // Swap positions
        const oldPos = currentPosition.value;
        if (clampedPosition > oldPos) {
          for (let i = oldPos; i < clampedPosition; i++) {
            newPositions[positions.value.indexOf(i + 1)] = i;
          }
        } else {
          for (let i = oldPos; i > clampedPosition; i--) {
            newPositions[positions.value.indexOf(i - 1)] = i;
          }
        }
        newPositions[index] = clampedPosition;
        positions.value = newPositions;
        
        currentPosition.value = clampedPosition;
      }
    })
    .onEnd(() => {
      isDragging.value = false;
      
      const finalPosition = positions.value[index];
      translateY.value = withSpring((finalPosition - index) * ITEM_HEIGHT);
      
      if (finalPosition !== index) {
        runOnJS(onDragEnd)(index, finalPosition);
      }
    });
  
  const composedGesture = Gesture.Simultaneous(longPressGesture, panGesture);
  
  const animatedStyle = useAnimatedStyle(() => ({
    transform: [
      { translateY: translateY.value },
      { scale: isDragging.value ? withSpring(1.05) : withSpring(1) },
    ],
    zIndex: isDragging.value ? 100 : 0,
    shadowOpacity: isDragging.value ? withTiming(0.3) : withTiming(0),
    elevation: isDragging.value ? 10 : 0,
  }));
  
  return (
    <GestureDetector gesture={composedGesture}>
      <Animated.View style={[styles.item, animatedStyle]}>
        <View style={styles.handle}>
          <Text style={styles.handleText}>☰</Text>
        </View>
        <Text style={styles.itemText}>{item.title}</Text>
      </Animated.View>
    </GestureDetector>
  );
}

function SortableList() {
  const [items, setItems] = useState<Item[]>([
    { id: '1', title: 'Item 1' },
    { id: '2', title: 'Item 2' },
    { id: '3', title: 'Item 3' },
    { id: '4', title: 'Item 4' },
    { id: '5', title: 'Item 5' },
  ]);
  
  const positions = useSharedValue(items.map((_, i) => i));
  
  const handleDragEnd = (from: number, to: number) => {
    const newItems = [...items];
    const [removed] = newItems.splice(from, 1);
    newItems.splice(to, 0, removed);
    setItems(newItems);
    positions.value = newItems.map((_, i) => i);
  };
  
  return (
    <View style={styles.container}>
      {items.map((item, index) => (
        <DraggableItem
          key={item.id}
          item={item}
          index={index}
          positions={positions}
          itemCount={items.length}
          onDragEnd={handleDragEnd}
        />
      ))}
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    padding: 16,
  },
  item: {
    flexDirection: 'row',
    alignItems: 'center',
    height: ITEM_HEIGHT - 10,
    marginBottom: 10,
    backgroundColor: 'white',
    borderRadius: 12,
    padding: 16,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 2 },
    shadowRadius: 4,
  },
  handle: {
    marginRight: 12,
  },
  handleText: {
    fontSize: 20,
    color: '#999',
  },
  itemText: {
    fontSize: 16,
  },
});

Summary

React Native Gesture Handler enables native-quality touch interactions that run on the UI thread for consistently smooth 60fps experiences. Combined with Reanimated, it forms the foundation for building complex, interactive mobile UIs.

🎯 Key Takeaways

  • Native thread: Gestures run on UI thread, not JS thread
  • Modern API: Use Gesture.Pan(), Gesture.Tap(), etc. with GestureDetector
  • Gesture types:
    • Tap (single, double, multi-tap)
    • Pan (drag, swipe)
    • Pinch (scale)
    • Rotation
    • Long Press
    • Fling
  • Composition:
    • Simultaneous — All gestures active at once
    • Race — First to activate wins
    • Exclusive — Priority-based blocking
  • Reanimated integration: Gesture callbacks are worklets—directly modify shared values

Common Gesture Patterns

Interaction Gestures Composition
Image viewer Pan + Pinch + DoubleTap Simultaneous
Swipe cards Pan Single
Bottom sheet Pan (vertical only) Single
Sortable list LongPress + Pan Simultaneous
Like button DoubleTap + SingleTap Exclusive

In the next lesson, we'll explore layout animations to smoothly animate components as they enter, exit, and change position in the layout.