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. withGestureDetector - Gesture types:
- Tap (single, double, multi-tap)
- Pan (drag, swipe)
- Pinch (scale)
- Rotation
- Long Press
- Fling
- Composition:
Simultaneous— All gestures active at onceRace— First to activate winsExclusive— 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.