mobile-animation-gesture-handler
React Native Gesture Handler - gesture types, GestureDetector, gesture composition, state machine, platform-specific gestures, swipeable rows, hover gestures
Install
npx skills add https://github.com/agents-inc/skills/tree/main/dist/plugins/mobile-animation-gesture-handler/skills/mobile-animation-gesture-handler
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install agents-inc-skills@llmmart
git clone https://github.com/agents-inc/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole agents-inc/skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
React Native Gesture Handler Patterns
Quick Guide: Use Gesture Handler's v2 builder API (
Gesture.Pan(),Gesture.Tap(), etc.) withGestureDetectorfor all touch interactions. Wrap your app root inGestureHandlerRootView. Compose gestures withGesture.Simultaneous(),Gesture.Race(), andGesture.Exclusive(). UseonChange(notonUpdate) when working with animation shared values --onChangeprovides deltas (changeX),onUpdateprovides cumulative values (translationX). Gesture callbacks are automatically workletized when your animation library is installed.
<critical_requirements>
CRITICAL: Before Using This Skill
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
import type, named constants)
(You MUST wrap the app root in GestureHandlerRootView -- gestures will silently fail without it)
(You MUST use GestureDetector with the builder API (Gesture.Pan(), etc.) -- NOT the legacy PanGestureHandler components)
(You MUST use onChange for incremental shared value updates and onUpdate for cumulative values -- mixing them causes drift)
(You MUST use Gesture.Simultaneous() for multi-touch interactions (pinch + pan) -- without it, only one gesture activates)
(You MUST NOT nest GestureDetector components using different API styles (hooks vs builder) under the same root -- this causes undefined behavior)
</critical_requirements>
Auto-detection: react-native-gesture-handler, GestureDetector, GestureHandlerRootView, Gesture.Pan, Gesture.Tap, Gesture.Pinch, Gesture.Rotation, Gesture.LongPress, Gesture.Fling, Gesture.Hover, Gesture.Simultaneous, Gesture.Race, Gesture.Exclusive, Swipeable, ReanimatedSwipeable, onBegin, onStart, onChange, onUpdate, onEnd, onFinalize
When to use:
- Adding pan, pinch, tap, rotation, long-press, fling, or hover gestures to React Native views
- Composing multiple gestures on the same view (pinch-to-zoom + drag)
- Building swipeable list rows with reveal actions
- Replacing React Native's built-in Gesture Responder System (PanResponder)
- Implementing gesture-driven animations with shared values
- Adding hover interactions for iPad trackpad, desktop, or web targets
Key patterns covered:
- GestureDetector + builder API for all gesture types
- Gesture composition: Simultaneous, Race, Exclusive
- Gesture state machine and lifecycle callbacks
- Shared value integration in gesture callbacks (onChange vs onUpdate)
- ReanimatedSwipeable for swipeable list rows
- Hover gesture for pointer devices (iPad trackpad, mouse, stylus)
- Platform-specific gesture configuration (Android ripple, iOS haptics)
- Cross-component gesture relations (requireToFail, simultaneousWith, block)
When NOT to use:
- Simple button taps (use
PressableorTouchableOpacityfrom React Native core) - Scroll-only interactions (use
ScrollVieworFlatListdirectly) - Web-only applications without React Native
Detailed Resources:
- examples/core.md - GestureDetector setup, pan, tap, pinch, rotation gestures with shared values
- examples/composition.md - Simultaneous, Race, Exclusive composition, cross-component relations
- examples/swipeable.md - ReanimatedSwipeable rows, FlatList integration, action panels
- examples/advanced.md - Hover gesture, manual gesture control, platform-specific config
- reference.md - Decision frameworks, gesture type reference, state machine diagram
<decision_framework>
Decision Framework
Which Gesture Type?
What user interaction are you handling?
├─ Drag/move element → Gesture.Pan()
├─ Single tap → Gesture.Tap()
├─ Double tap → Gesture.Tap().numberOfTaps(2)
├─ Long press → Gesture.LongPress()
├─ Pinch to zoom → Gesture.Pinch()
├─ Two-finger rotate → Gesture.Rotation()
├─ Quick directional swipe → Gesture.Fling()
├─ Mouse/trackpad hover → Gesture.Hover()
└─ Wrap a native gesture → Gesture.Native() (for ScrollView, etc.)
Which Composition?
How should multiple gestures interact?
├─ All active at once (pan + pinch + rotate in photo viewer)
│ └─ Gesture.Simultaneous(gesture1, gesture2, ...)
├─ First to activate wins (swipe vs long-press on same view)
│ └─ Gesture.Race(gesture1, gesture2, ...)
├─ Priority order (double-tap must beat single-tap)
│ └─ Gesture.Exclusive(highPriority, lowPriority)
└─ Gestures on DIFFERENT components (child drag vs parent scroll)
├─ Parent waits for child to fail → parent.requireExternalGestureToFail(child)
├─ Both active simultaneously → child.simultaneousWith(parent)
└─ Child blocks parent → child.blocksExternalGesture(parent)
onChange vs onUpdate?
How are you using the gesture position data?
├─ Adding to an offset (shared value += delta)
│ └─ Use onChange (provides changeX, changeY)
├─ Setting absolute position from gesture start
│ └─ Use onUpdate (provides translationX, translationY)
└─ Need both (rare)
└─ Use onChange for offsets + onUpdate for display values
</decision_framework>
<red_flags>
RED FLAGS
High Priority Issues:
- Missing
GestureHandlerRootViewat app root -- gestures silently fail with no error message - Using legacy
PanGestureHandler/TapGestureHandlercomponents -- deprecated, useGesture.Pan()+GestureDetector - Using
onUpdateto add deltas to shared values --onUpdateprovides cumulative values, not deltas; useonChangefor incremental updates - Nesting multiple
GestureDetectorcomponents for gestures that should compose -- useGesture.Simultaneous()/Race()/Exclusive()instead - Using
React.PanResponder-- replaced entirely by Gesture Handler; PanResponder runs on JS thread and blocks the UI
Medium Priority Issues:
- Not specifying
minDistanceonGesture.Pan()when coexisting with tap gestures -- pan activates immediately at 0px, stealing taps - Missing
onFinalizecleanup --onEndonly fires on success; cancelled/failed gestures skip it - Using
Swipeableinstead ofReanimatedSwipeable-- legacy component runs animations on JS thread - Creating new gesture instances inside render (not in useMemo or outside component) -- causes gesture to reset every render
Gotchas & Edge Cases:
Gesture.Exclusive(doubleTap, singleTap)-- double-tap MUST be the first argument (higher priority) or it will never fire because single-tap activates first- Android modals need their own
GestureHandlerRootView-- gestures in modals fail silently without it onChangecallback is NOT available in the v3 hooks API -- it was removed; useonUpdatewith thechange*properties on the event object instead- Gesture callbacks are automatically workletized when your animation library is installed -- don't manually add
"worklet"directives unless you need them without the animation library Gesture.Native()wraps platform-native gestures (ScrollView, FlatList) -- use it when you need to create relations between custom gestures and native scrollingonFinalizereceives(event, success)-- thesuccessboolean tells you whether the gesture ended normally (END) or was cancelled/failed- Reusing the same gesture instance across multiple
GestureDetectorcomponents causes undefined behavior -- create separate instances - iPad trackpad two-finger gestures require
enableTrackpadTwoFingerGestureon both Pan andReanimatedSwipeable hoverEffectprop on GestureDetector only works on iOS 17.0+ and provides system-level visual effectsGesture.Fling()only fires at the end of the fling, not continuously -- useGesture.Pan()if you need continuous position tracking during a swipe
</red_flags>
<critical_reminders>
CRITICAL REMINDERS
All code must follow project conventions in CLAUDE.md
(You MUST wrap the app root in GestureHandlerRootView -- gestures will silently fail without it)
(You MUST use GestureDetector with the builder API (Gesture.Pan(), etc.) -- NOT the legacy PanGestureHandler components)
(You MUST use onChange for incremental shared value updates and onUpdate for cumulative values -- mixing them causes drift)
(You MUST use Gesture.Simultaneous() for multi-touch interactions (pinch + pan) -- without it, only one gesture activates)
(You MUST NOT nest GestureDetector components using different API styles (hooks vs builder) under the same root -- this causes undefined behavior)
Failure to follow these rules will cause silent gesture failures, animation drift, and broken multi-touch interactions.
</critical_reminders>
Files (skills)
-
examples
-
advanced.md 6.7 KB
# Gesture Handler - Advanced Patterns > Hover gestures, manual control, platform config. See [core.md](core.md) for fundamentals, [composition.md](composition.md) for multi-gesture. **Related:** [SKILL.md](../SKILL.md) for decision framework, [reference.md](../reference.md) for gesture type table. --- ## Pattern 1: Hover Gesture (Pointer Devices) Hover gestures detect mouse, stylus, or trackpad hovering. They fire on iPad (with trackpad/mouse), macOS, and web -- but NOT on phone touch. ```typescript import { Gesture, GestureDetector } from "react-native-gesture-handler"; import Animated, { useSharedValue, useAnimatedStyle, withTiming, } from "react-native-reanimated"; const HOVER_SCALE = 1.05; const TIMING_CONFIG = { duration: 150 }; function HoverableCard({ children }: { children: React.ReactNode }) { const isHovered = useSharedValue(false); const hover = Gesture.Hover() .onBegin(() => { isHovered.value = true; }) .onEnd(() => { isHovered.value = false; }); const animatedStyle = useAnimatedStyle(() => ({ transform: [ { scale: withTiming( isHovered.value ? HOVER_SCALE : 1, TIMING_CONFIG, ), }, ], shadowOpacity: withTiming(isHovered.value ? 0.2 : 0.1, TIMING_CONFIG), })); return ( <GestureDetector gesture={hover}> <Animated.View style={[styles.card, animatedStyle]}> {children} </Animated.View> </GestureDetector> ); } ``` **Why good:** `onBegin`/`onEnd` for hover enter/exit, `withTiming` for smooth transitions, works on iPad trackpad and web ### iOS System Hover Effects On iOS 17.0+, `GestureDetector` supports a `hoverEffect` prop for system-provided visual effects. These are lightweight alternatives to custom hover animations. ```typescript <GestureDetector gesture={tap} hoverEffect="lift"> <Animated.View style={styles.button}> <Text>Hover me</Text> </Animated.View> </GestureDetector> ``` Available effects: `"highlight"` (dim overlay), `"lift"` (raise with shadow), `"automatic"` (system chooses). **Platform note:** `hoverEffect` only works on iOS 17.0+ with pointer devices. It is ignored on Android and earlier iOS versions. --- ## Pattern 2: Manual Gesture (Custom Recognition Logic) Use `Gesture.Manual()` when none of the built-in gestures match your recognition criteria. You control state transitions manually via the `GestureStateManager`. ```typescript import { Gesture, GestureDetector, GestureStateManager, } from "react-native-gesture-handler"; const MIN_MOVEMENT = 30; const MAX_TIME_MS = 500; function CustomGestureView() { const gesture = Gesture.Manual() .onTouchesDown((event, stateManager) => { // Only recognize single-finger touches if (event.numberOfTouches === 1) { stateManager.begin(); } else { stateManager.fail(); } }) .onTouchesMove((event, stateManager) => { const touch = event.allTouches[0]; if (touch && Math.abs(touch.absoluteX - touch.x) > MIN_MOVEMENT) { stateManager.activate(); } }) .onTouchesUp((_event, stateManager) => { stateManager.end(); }); return ( <GestureDetector gesture={gesture}> <View style={styles.container} /> </GestureDetector> ); } ``` **Why good:** full control over state transitions (begin, activate, fail, end), useful for gestures that don't fit any standard type **When to use:** complex multi-step gesture recognition, gestures combining time + distance thresholds, or custom shapes (circle gesture, Z-pattern, etc.). --- ## Pattern 3: Platform-Specific Gesture Config ```typescript import { Platform } from "react-native"; const PAN_MIN_DISTANCE_IOS = 10; const PAN_MIN_DISTANCE_ANDROID = 15; // Android touch slop is slightly larger const pan = Gesture.Pan() .minDistance( Platform.select({ ios: PAN_MIN_DISTANCE_IOS, android: PAN_MIN_DISTANCE_ANDROID, default: PAN_MIN_DISTANCE_IOS, }), ) .onChange((event) => { translateX.value += event.changeX; }); ``` **Why good:** Android has a larger default touch slop than iOS; matching platform defaults prevents gestures feeling too sensitive or too sluggish per platform ### iPad Trackpad Two-Finger Pan ```typescript const pan = Gesture.Pan() .enableTrackpadTwoFingerGesture(true) .onChange((event) => { translateX.value += event.changeX; }); ``` **Why good:** enables two-finger trackpad swiping on iPad, which is the standard gesture for horizontal navigation; without this flag, trackpad two-finger gestures are not recognized --- ## Pattern 4: Gesture with runOnJS Bridge When gesture callbacks need to trigger JS-thread operations (navigation, API calls, state updates in non-worklet stores), use `runOnJS`. ```typescript import { runOnJS } from "react-native-reanimated"; function SwipeToDismiss({ onDismiss }: { onDismiss: () => void }) { const translateX = useSharedValue(0); const DISMISS_THRESHOLD = 150; const pan = Gesture.Pan() .onChange((event) => { translateX.value += event.changeX; }) .onEnd(() => { if (Math.abs(translateX.value) > DISMISS_THRESHOLD) { // Bridge to JS thread for navigation/state runOnJS(onDismiss)(); } else { translateX.value = withSpring(0); } }); // ... } ``` **Why good:** gesture callbacks run on UI thread (worklets) for performance; `runOnJS` safely bridges back to JS when needed for non-animation logic **Gotcha:** Do NOT call `runOnJS` inside `onChange`/`onUpdate` (fires every frame) -- only in `onEnd`/`onFinalize`/`onStart` to avoid flooding the JS thread. --- ## Pattern 5: Fling Gesture (Quick Directional Swipe) Unlike Pan, Fling fires once at the end of a quick swipe in a specific direction. Use it for page navigation or dismissal, not for continuous tracking. ```typescript import { Directions } from "react-native-gesture-handler"; function FlingNavigator({ onSwipeLeft, onSwipeRight }: NavigatorProps) { const swipeLeft = Gesture.Fling() .direction(Directions.LEFT) .onStart(() => { runOnJS(onSwipeLeft)(); }); const swipeRight = Gesture.Fling() .direction(Directions.RIGHT) .onStart(() => { runOnJS(onSwipeRight)(); }); const flings = Gesture.Simultaneous(swipeLeft, swipeRight); return ( <GestureDetector gesture={flings}> <View style={styles.page}>{/* Page content */}</View> </GestureDetector> ); } ``` **Why good:** Fling is optimized for quick swipes, fires once with direction info, `Simultaneous` allows both directions to be recognized **When to use Fling vs Pan:** Use `Fling` for discrete navigation actions (go to next page). Use `Pan` when you need continuous position tracking during the swipe (dragging a card). -
composition.md 8 KB
# Gesture Handler - Composition Patterns > Multi-gesture composition and cross-component relations. See [core.md](core.md) for individual gesture types. **Related:** [SKILL.md](../SKILL.md) for composition decision framework, [reference.md](../reference.md) for quick reference tables. --- ## Pattern 1: Simultaneous Pan + Pinch + Rotation (Photo Viewer) The classic image viewer: drag, zoom, and rotate all at once. ```typescript import { Gesture, GestureDetector } from "react-native-gesture-handler"; import Animated, { useSharedValue, useAnimatedStyle, withTiming, } from "react-native-reanimated"; function PhotoViewer({ source }: { source: ImageSourcePropType }) { // Pan state const translateX = useSharedValue(0); const translateY = useSharedValue(0); // Pinch state const scale = useSharedValue(1); const savedScale = useSharedValue(1); // Rotation state const rotation = useSharedValue(0); const savedRotation = useSharedValue(0); const pan = Gesture.Pan() .onChange((event) => { translateX.value += event.changeX; translateY.value += event.changeY; }); const pinch = Gesture.Pinch() .onUpdate((event) => { scale.value = savedScale.value * event.scale; }) .onEnd(() => { savedScale.value = scale.value; }); const rotate = Gesture.Rotation() .onUpdate((event) => { rotation.value = savedRotation.value + event.rotation; }) .onEnd(() => { savedRotation.value = rotation.value; }); // All three gestures active at the same time const composed = Gesture.Simultaneous(pan, pinch, rotate); const animatedStyle = useAnimatedStyle(() => ({ transform: [ { translateX: translateX.value }, { translateY: translateY.value }, { scale: scale.value }, { rotate: `${rotation.value}rad` }, ], })); return ( <GestureDetector gesture={composed}> <Animated.Image source={source} style={[styles.image, animatedStyle]} /> </GestureDetector> ); } ``` **Why good:** single `GestureDetector` with composed gesture, each gesture manages its own shared values, `Simultaneous` allows all three to be ACTIVE at once ```typescript // BAD: Separate GestureDetectors for gestures that should interact <GestureDetector gesture={pan}> <GestureDetector gesture={pinch}> <GestureDetector gesture={rotate}> <Animated.Image source={source} style={animatedStyle} /> </GestureDetector> </GestureDetector> </GestureDetector> ``` **Why bad:** nested detectors without explicit composition causes gestures to compete by default -- only one activates, the rest fail --- ## Pattern 2: Exclusive Double-Tap vs Single-Tap Double-tap and single-tap on the same element. Double-tap must have higher priority. ```typescript const DOUBLE_TAP_MAX_DELAY = 250; function TapableCard({ onTap, onDoubleTap }: TapHandlers) { const doubleTap = Gesture.Tap() .numberOfTaps(2) .maxDelay(DOUBLE_TAP_MAX_DELAY) .onStart(() => { runOnJS(onDoubleTap)(); }); const singleTap = Gesture.Tap() .onStart(() => { runOnJS(onTap)(); }); // CRITICAL: doubleTap FIRST = higher priority const taps = Gesture.Exclusive(doubleTap, singleTap); return ( <GestureDetector gesture={taps}> <View style={styles.card}> <Text>Tap or double-tap</Text> </View> </GestureDetector> ); } ``` **Why good:** `Exclusive` with doubleTap first means the system waits to see if a second tap comes; if it does, doubleTap activates; if not, singleTap activates after the delay ```typescript // BAD: Wrong priority order const taps = Gesture.Exclusive(singleTap, doubleTap); ``` **Why bad:** singleTap has higher priority, fires immediately on first tap, doubleTap never gets recognized --- ## Pattern 3: Race -- Swipe vs Long Press When two gestures compete and only one should win. ```typescript const LONG_PRESS_DURATION = 400; const MIN_SWIPE_DISTANCE = 50; function MessageBubble({ onSwipeReply, onLongPressMenu }: MessageHandlers) { const translateX = useSharedValue(0); const swipe = Gesture.Pan() .activeOffsetX([-MIN_SWIPE_DISTANCE, MIN_SWIPE_DISTANCE]) .onChange((event) => { translateX.value += event.changeX; }) .onFinalize(() => { if (Math.abs(translateX.value) > MIN_SWIPE_DISTANCE) { runOnJS(onSwipeReply)(); } translateX.value = withTiming(0); }); const longPress = Gesture.LongPress() .minDuration(LONG_PRESS_DURATION) .onStart(() => { runOnJS(onLongPressMenu)(); }); // First gesture to activate wins -- the other fails const gesture = Gesture.Race(swipe, longPress); return ( <GestureDetector gesture={gesture}> <Animated.View style={animatedStyle}> <Text>Message content</Text> </Animated.View> </GestureDetector> ); } ``` **Why good:** `Race` ensures only one interaction happens -- either the user swipes (reply) or long-presses (context menu), never both; `activeOffsetX` delays pan activation until meaningful horizontal movement --- ## Pattern 4: Cross-Component Relations (Drag Inside ScrollView) When a draggable child lives inside a scrollable parent, use cross-component relations. ```typescript function DraggableInScrollView() { const childPan = Gesture.Pan() .onChange((event) => { translateY.value += event.changeY; }); // Wrap the native ScrollView gesture so we can reference it const scrollGesture = Gesture.Native(); // Method 1: Scroll waits for drag to fail // (scroll only works when not touching the draggable child) scrollGesture.requireExternalGestureToFail(childPan); // Method 2: Both active simultaneously // (scroll AND drag work at the same time -- rare but useful for parallax) // childPan.simultaneousWith(scrollGesture); // Method 3: Child blocks scroll while dragging // (scroll stops when dragging starts) // childPan.blocksExternalGesture(scrollGesture); return ( <GestureDetector gesture={scrollGesture}> <Animated.ScrollView> <View style={styles.content}> <GestureDetector gesture={childPan}> <Animated.View style={[styles.draggable, animatedStyle]} /> </GestureDetector> </View> </Animated.ScrollView> </GestureDetector> ); } ``` **Key differences from composition:** | Same component | Different components | | ---------------------------- | ----------------------------------- | | `Gesture.Simultaneous(a, b)` | `a.simultaneousWith(b)` | | `Gesture.Race(a, b)` | `a.requireExternalGestureToFail(b)` | | `Gesture.Exclusive(a, b)` | `a.blocksExternalGesture(b)` | **Why this distinction matters:** Composition methods create a single composed gesture for one `GestureDetector`. Cross-component relations connect gestures on separate `GestureDetector` components in the view hierarchy. --- ## Pattern 5: Pan with Axis Locking Restrict pan to horizontal or vertical based on initial movement direction. ```typescript const AXIS_LOCK_THRESHOLD = 15; function AxisLockedPan() { const translateX = useSharedValue(0); const translateY = useSharedValue(0); const horizontalPan = Gesture.Pan() .activeOffsetX([-AXIS_LOCK_THRESHOLD, AXIS_LOCK_THRESHOLD]) .failOffsetY([-AXIS_LOCK_THRESHOLD, AXIS_LOCK_THRESHOLD]) .onChange((event) => { translateX.value += event.changeX; }); const verticalPan = Gesture.Pan() .activeOffsetY([-AXIS_LOCK_THRESHOLD, AXIS_LOCK_THRESHOLD]) .failOffsetX([-AXIS_LOCK_THRESHOLD, AXIS_LOCK_THRESHOLD]) .onChange((event) => { translateY.value += event.changeY; }); // Only one direction wins const pan = Gesture.Race(horizontalPan, verticalPan); return ( <GestureDetector gesture={pan}> <Animated.View style={animatedStyle} /> </GestureDetector> ); } ``` **Why good:** `activeOffsetX` + `failOffsetY` means the horizontal pan activates when horizontal movement exceeds threshold BUT fails if vertical movement exceeds threshold first. The `Race` composition ensures only one direction wins. -
core.md 8.2 KB
# Gesture Handler - Core Patterns > Fundamental gesture setup and individual gesture types. See [SKILL.md](../SKILL.md) for decision guidance and red flags. **Related:** [composition.md](composition.md) for multi-gesture patterns, [swipeable.md](swipeable.md) for list rows, [advanced.md](advanced.md) for hover and manual control. --- ## Pattern 1: App Root Setup ```typescript import { GestureHandlerRootView } from "react-native-gesture-handler"; // CRITICAL: Must be at the actual root of the app export function App() { return ( <GestureHandlerRootView style={{ flex: 1 }}> {/* Your navigation, providers, etc. */} <AppContent /> </GestureHandlerRootView> ); } ``` **Why good:** single root, flex: 1 covers full screen, all gestures and relations work within this root ```typescript // BAD: Missing GestureHandlerRootView export function App() { return <AppContent />; // Gestures silently fail everywhere } ``` **Why bad:** gestures won't be recognized anywhere in the app, and no error is thrown ### Android Modal Gotcha ```typescript import { Modal } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; // Android modals create a separate native view hierarchy function MyModal({ visible, onClose }: ModalProps) { return ( <Modal visible={visible} onRequestClose={onClose}> <GestureHandlerRootView style={{ flex: 1 }}> <ModalContent /> </GestureHandlerRootView> </Modal> ); } ``` **Why good:** Modal on Android has its own view hierarchy; without a separate root, gestures inside modals fail silently --- ## Pattern 2: Pan Gesture (Drag) Two approaches depending on how you use position data: ### onChange for Offset Accumulation (Most Common) ```typescript import { Gesture, GestureDetector } from "react-native-gesture-handler"; import Animated, { useSharedValue, useAnimatedStyle, withDecay, } from "react-native-reanimated"; const MIN_PAN_DISTANCE = 10; function DraggableBox() { const translateX = useSharedValue(0); const translateY = useSharedValue(0); const pan = Gesture.Pan() .minDistance(MIN_PAN_DISTANCE) .onChange((event) => { // onChange provides DELTAS (changeX, changeY) translateX.value += event.changeX; translateY.value += event.changeY; }) .onFinalize((event) => { // Decay animation using final velocity translateX.value = withDecay({ velocity: event.velocityX }); translateY.value = withDecay({ velocity: event.velocityY }); }); const animatedStyle = useAnimatedStyle(() => ({ transform: [ { translateX: translateX.value }, { translateY: translateY.value }, ], })); return ( <GestureDetector gesture={pan}> <Animated.View style={[styles.box, animatedStyle]} /> </GestureDetector> ); } ``` **Why good:** `onChange` gives deltas, so `+=` accumulates correctly; `onFinalize` runs on any terminal state; `minDistance` prevents accidental activation ### onUpdate for Absolute Position ```typescript function DraggableFromOrigin() { const startX = useSharedValue(0); const translateX = useSharedValue(0); const pan = Gesture.Pan() .onStart(() => { // Save current position at gesture start startX.value = translateX.value; }) .onUpdate((event) => { // onUpdate provides CUMULATIVE translation from gesture start translateX.value = startX.value + event.translationX; }); // ... animatedStyle and render same as above } ``` **Why good:** `onUpdate` gives cumulative translation, combined with saved start position for absolute positioning ```typescript // BAD: Mixing onChange deltas with onUpdate cumulative values const pan = Gesture.Pan().onUpdate((event) => { translateX.value += event.translationX; // WRONG: translationX is cumulative, not delta }); ``` **Why bad:** `translationX` grows each frame, so `+=` causes exponential drift instead of linear movement --- ## Pattern 3: Tap Gesture ```typescript const TAP_MAX_DURATION = 300; function TappableCard({ onTap }: { onTap: () => void }) { const scale = useSharedValue(1); const tap = Gesture.Tap() .maxDuration(TAP_MAX_DURATION) .onBegin(() => { // Visual feedback when touch starts (BEGAN state) scale.value = withTiming(0.95); }) .onStart(() => { // Gesture recognized (ACTIVE state) -- fire action runOnJS(onTap)(); }) .onFinalize(() => { // Reset regardless of success/failure/cancel scale.value = withTiming(1); }); const animatedStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }], })); return ( <GestureDetector gesture={tap}> <Animated.View style={[styles.card, animatedStyle]}> <Text>Tap me</Text> </Animated.View> </GestureDetector> ); } ``` **Why good:** `onBegin` for immediate visual feedback (before recognition), `onStart` for the action, `onFinalize` for guaranteed cleanup, `runOnJS` bridges worklet back to JS ### Double Tap ```typescript const DOUBLE_TAP_MAX_DELAY = 250; const doubleTap = Gesture.Tap() .numberOfTaps(2) .maxDelay(DOUBLE_TAP_MAX_DELAY) .onStart(() => { scale.value = scale.value === 1 ? 2 : 1; }); ``` **Gotcha:** When combining single and double tap on the same view, use `Gesture.Exclusive(doubleTap, singleTap)` -- double-tap must be the FIRST argument (higher priority). If single-tap goes first, it fires immediately and double-tap never gets a chance. --- ## Pattern 4: Pinch Gesture (Zoom) ```typescript function PinchableImage({ source }: { source: ImageSourcePropType }) { const scale = useSharedValue(1); const savedScale = useSharedValue(1); const pinch = Gesture.Pinch() .onUpdate((event) => { // event.scale is relative to gesture start (1.0 = no change) scale.value = savedScale.value * event.scale; }) .onEnd(() => { savedScale.value = scale.value; }); const animatedStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }], })); return ( <GestureDetector gesture={pinch}> <Animated.Image source={source} style={[styles.image, animatedStyle]} /> </GestureDetector> ); } ``` **Why good:** `savedScale` preserves accumulated scale between gestures; `event.scale` is multiplicative (1.0 = unchanged, 2.0 = doubled) **Gotcha:** `event.scale` starts at 1.0 for each gesture, not from the previous scale. Always multiply by the saved value. --- ## Pattern 5: Rotation Gesture ```typescript function RotatableView() { const rotation = useSharedValue(0); const savedRotation = useSharedValue(0); const rotationGesture = Gesture.Rotation() .onUpdate((event) => { // event.rotation is in radians, relative to gesture start rotation.value = savedRotation.value + event.rotation; }) .onEnd(() => { savedRotation.value = rotation.value; }); const animatedStyle = useAnimatedStyle(() => ({ transform: [{ rotate: `${rotation.value}rad` }], })); return ( <GestureDetector gesture={rotationGesture}> <Animated.View style={[styles.dial, animatedStyle]} /> </GestureDetector> ); } ``` **Why good:** same saved-value pattern as pinch; rotation in radians is additive (not multiplicative like scale) --- ## Pattern 6: Long Press Gesture ```typescript const LONG_PRESS_DURATION = 500; function LongPressableItem({ onLongPress }: { onLongPress: () => void }) { const isPressed = useSharedValue(false); const longPress = Gesture.LongPress() .minDuration(LONG_PRESS_DURATION) .onBegin(() => { isPressed.value = true; // Immediate visual feedback }) .onStart(() => { // Fires after minDuration elapses with finger still down runOnJS(onLongPress)(); }) .onFinalize(() => { isPressed.value = false; }); // ... animated style using isPressed.value for opacity/scale return ( <GestureDetector gesture={longPress}> <Animated.View style={animatedStyle}> <Text>Hold me</Text> </Animated.View> </GestureDetector> ); } ``` **Key distinction:** `onBegin` fires when touch is detected (visual feedback). `onStart` fires only after `minDuration` milliseconds with finger still down (action trigger). If the user lifts their finger before `minDuration`, the gesture transitions to FAILED and `onStart` never fires. -
swipeable.md 6.3 KB
# Gesture Handler - Swipeable Patterns > ReanimatedSwipeable for list row interactions. See [core.md](core.md) for gesture fundamentals. **Related:** [SKILL.md](../SKILL.md) for red flags, [reference.md](../reference.md) for ReanimatedSwipeable props. --- ## Pattern 1: Swipeable List Row with Delete Action ```typescript import { useCallback, useRef } from "react"; import { Text, View, StyleSheet, Pressable } from "react-native"; import ReanimatedSwipeable, { type SwipeableMethods, } from "react-native-gesture-handler/ReanimatedSwipeable"; import Animated, { useAnimatedStyle, type SharedValue, } from "react-native-reanimated"; const SWIPE_THRESHOLD = 40; const OVERSHOOT_FRICTION = 8; const DELETE_ACTION_WIDTH = 80; function RightActions( progress: SharedValue<number>, translation: SharedValue<number>, ) { const animatedStyle = useAnimatedStyle(() => ({ // Slide in from the right, anchored to the swipe translation transform: [{ translateX: translation.value + DELETE_ACTION_WIDTH }], })); return ( <Animated.View style={[styles.rightAction, animatedStyle]}> <Text style={styles.actionText}>Delete</Text> </Animated.View> ); } interface SwipeableRowProps { item: { id: string; title: string }; onDelete: (id: string) => void; } function SwipeableRow({ item, onDelete }: SwipeableRowProps) { const swipeableRef = useRef<SwipeableMethods>(null); const handleSwipeOpen = useCallback(() => { onDelete(item.id); swipeableRef.current?.close(); }, [item.id, onDelete]); return ( <ReanimatedSwipeable ref={swipeableRef} friction={2} rightThreshold={SWIPE_THRESHOLD} overshootFriction={OVERSHOOT_FRICTION} renderRightActions={RightActions} onSwipeableOpen={handleSwipeOpen} > <View style={styles.row}> <Text style={styles.rowText}>{item.title}</Text> </View> </ReanimatedSwipeable> ); } const styles = StyleSheet.create({ row: { padding: 16, backgroundColor: "#FFFFFF", borderBottomWidth: 1, borderBottomColor: "#E5E5E5", }, rowText: { fontSize: 16, color: "#1A1A1A", }, rightAction: { width: DELETE_ACTION_WIDTH, backgroundColor: "#FF3B30", justifyContent: "center", alignItems: "center", }, actionText: { color: "#FFFFFF", fontWeight: "600", fontSize: 14, }, }); ``` **Why good:** `ReanimatedSwipeable` runs animations on native thread (not JS), `overshootFriction: 8` gives native-feeling resistance, ref for programmatic control, `onSwipeableOpen` fires when the user completes the swipe --- ## Pattern 2: Swipeable with Left + Right Actions ```typescript const ARCHIVE_ACTION_WIDTH = 80; const DELETE_ACTION_WIDTH = 80; function LeftActions( progress: SharedValue<number>, translation: SharedValue<number>, ) { const animatedStyle = useAnimatedStyle(() => ({ transform: [{ translateX: translation.value - ARCHIVE_ACTION_WIDTH }], })); return ( <Animated.View style={[styles.archiveAction, animatedStyle]}> <Text style={styles.actionText}>Archive</Text> </Animated.View> ); } function RightActions( progress: SharedValue<number>, translation: SharedValue<number>, ) { const animatedStyle = useAnimatedStyle(() => ({ transform: [{ translateX: translation.value + DELETE_ACTION_WIDTH }], })); return ( <Animated.View style={[styles.deleteAction, animatedStyle]}> <Text style={styles.actionText}>Delete</Text> </Animated.View> ); } function EmailRow({ email, onArchive, onDelete }: EmailRowProps) { const swipeableRef = useRef<SwipeableMethods>(null); const handleOpen = useCallback( (direction: "left" | "right") => { if (direction === "left") { onArchive(email.id); } else { onDelete(email.id); } swipeableRef.current?.close(); }, [email.id, onArchive, onDelete], ); return ( <ReanimatedSwipeable ref={swipeableRef} friction={2} leftThreshold={ARCHIVE_ACTION_WIDTH} rightThreshold={DELETE_ACTION_WIDTH} overshootFriction={OVERSHOOT_FRICTION} renderLeftActions={LeftActions} renderRightActions={RightActions} onSwipeableOpen={handleOpen} > <View style={styles.emailRow}> <Text style={styles.subject}>{email.subject}</Text> <Text style={styles.preview}>{email.preview}</Text> </View> </ReanimatedSwipeable> ); } ``` **Why good:** `onSwipeableOpen` receives the direction string, actions slide in from their respective sides using translation offset --- ## Pattern 3: Swipeable in FlatList (Close Others on Open) ```typescript import { useCallback, useRef } from "react"; import { FlatList } from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; function SwipeableList({ items, onDelete }: SwipeableListProps) { // Track which swipeable is currently open const openSwipeableRef = useRef<SwipeableMethods | null>(null); const handleSwipeableOpen = useCallback((ref: SwipeableMethods) => { // Close the previously open row before opening the new one if (openSwipeableRef.current && openSwipeableRef.current !== ref) { openSwipeableRef.current.close(); } openSwipeableRef.current = ref; }, []); const renderItem = useCallback( ({ item }: { item: ListItem }) => ( <SwipeableRow item={item} onDelete={onDelete} onSwipeableOpen={handleSwipeableOpen} /> ), [onDelete, handleSwipeableOpen], ); return ( <FlatList data={items} renderItem={renderItem} keyExtractor={(item) => item.id} /> ); } // SwipeableRow passes its ref up when opened function SwipeableRow({ item, onDelete, onSwipeableOpen, }: SwipeableRowWithCallbackProps) { const swipeableRef = useRef<SwipeableMethods>(null); const handleOpen = useCallback(() => { if (swipeableRef.current) { onSwipeableOpen(swipeableRef.current); } }, [onSwipeableOpen]); return ( <ReanimatedSwipeable ref={swipeableRef} renderRightActions={RightActions} onSwipeableWillOpen={handleOpen} > <RowContent item={item} /> </ReanimatedSwipeable> ); } ``` **Why good:** `onSwipeableWillOpen` fires before animation completes (faster UX), only one row open at a time prevents visual clutter, ref-based approach avoids state re-renders
-
-
reference.md 8.3 KB
# Gesture Handler Quick Reference > Decision frameworks, state machine, and gesture type reference. See [SKILL.md](SKILL.md) for philosophy and red flags. --- ## Gesture State Machine ``` ┌───────────────── FAILED ──────────────────┐ │ │ UNDETERMINED ──> BEGAN ──> ACTIVE ──> END ──> UNDETERMINED │ │ │ └──> CANCELLED ─────────────────┘ ``` ### State Descriptions | State | Meaning | | ------------ | -------------------------------------------------------------- | | UNDETERMINED | Initial/reset state -- gesture is idle | | BEGAN | Touch detected, gathering data (not yet recognized) | | ACTIVE | Gesture recognized and tracking (finger still down) | | END | Gesture completed normally (finger lifted) | | FAILED | Touch didn't meet gesture criteria (e.g., exceeded maxDist) | | CANCELLED | System cancelled the gesture (another gesture won competition) | ### Common State Flows ``` Success: UNDETERMINED -> BEGAN -> ACTIVE -> END -> UNDETERMINED Rejection: UNDETERMINED -> BEGAN -> FAILED -> UNDETERMINED Cancellation: UNDETERMINED -> BEGAN -> ACTIVE -> CANCELLED -> UNDETERMINED ``` --- ## Lifecycle Callback Reference | Callback | State Transition | Event Data | Use Case | | ------------ | ----------------------- | ------------------------------- | --------------------------- | | `onBegin` | -> BEGAN | Touch position (x, y) | Visual feedback (highlight) | | `onStart` | BEGAN -> ACTIVE | Initial gesture data | Begin tracking | | `onUpdate` | While ACTIVE | Cumulative: translationX, scale | Absolute positioning | | `onChange` | While ACTIVE | Incremental: changeX, changeY | Offset accumulation | | `onEnd` | ACTIVE -> END | Velocity, final position | Success-only cleanup | | `onFinalize` | -> END/FAILED/CANCELLED | Event + success boolean | Guaranteed cleanup | **Key distinction:** - `onEnd` fires ONLY on successful completion (END state) - `onFinalize` fires on ANY terminal state (END, FAILED, CANCELLED) -- use for cleanup that must always happen --- ## Gesture Type Reference | Gesture | Activation Criteria | Key Config | Event Data | | --------------------- | ---------------------------------- | ------------------------------------------- | -------------------------------------- | | `Gesture.Pan()` | Finger moves beyond minDistance | `minDistance`, `minPointers`, `maxPointers` | translationX/Y, velocityX/Y, changeX/Y | | `Gesture.Tap()` | Quick touch within maxDuration | `numberOfTaps`, `maxDuration`, `maxDelay` | x, y, absoluteX, absoluteY | | `Gesture.LongPress()` | Touch held beyond minDuration | `minDuration`, `maxDist` | x, y, duration | | `Gesture.Pinch()` | Two fingers move closer/apart | -- | scale, velocity, focalX, focalY | | `Gesture.Rotation()` | Two fingers rotate | -- | rotation (radians), velocity | | `Gesture.Fling()` | Quick directional swipe | `direction`, `numberOfPointers` | x, y (fires only at end) | | `Gesture.Hover()` | Mouse/stylus enters view area | -- | x, y, absoluteX, absoluteY | | `Gesture.Native()` | Wraps platform ScrollView/FlatList | -- | Platform-dependent | --- ## Composition Quick Reference | Method | Behavior | When to Use | | ------------------------ | -------------------------------------------- | ----------------------------------------- | | `Gesture.Simultaneous()` | All gestures can be active at the same time | Pan + pinch + rotate (photo viewer) | | `Gesture.Race()` | First to activate wins, rest fail | Swipe vs long-press (competing actions) | | `Gesture.Exclusive()` | Priority by argument order (first = highest) | Double-tap vs single-tap (disambiguation) | ### Cross-Component Relations | Method | Direction | Effect | | ---------------------------------- | ------------- | ----------------------------------------- | | `.requireExternalGestureToFail(g)` | One-to-many | This gesture waits for `g` to fail first | | `.simultaneousWith(g)` | Bidirectional | Both gestures can be active together | | `.blocksExternalGesture(g)` | Many-to-one | This gesture prevents `g` from activating | --- ## Pan Configuration Cheat Sheet ```typescript Gesture.Pan() .minDistance(10) // Min px before ACTIVE (default: 10 on iOS, varies Android) .minPointers(1) // Min simultaneous fingers .maxPointers(1) // Max simultaneous fingers (2 for two-finger pan) .activeOffsetX([-20, 20]) // Horizontal threshold to activate .activeOffsetY([-20, 20]) // Vertical threshold to activate .failOffsetX([-50, 50]) // Horizontal offset that causes FAIL .failOffsetY([-50, 50]) // Vertical offset that causes FAIL .averageTouches(true) // Average multi-finger position .enableTrackpadTwoFingerGesture(true); // iPad trackpad support ``` --- ## Tap Configuration Cheat Sheet ```typescript Gesture.Tap() .numberOfTaps(2) // Required taps (default: 1) .maxDuration(500) // Max ms per tap (default: 500) .maxDelay(500) // Max ms between taps (default: 500) .maxDistance(10) // Max finger movement between taps .minPointers(1); // Min simultaneous fingers per tap ``` --- ## ReanimatedSwipeable Props Reference | Prop | Type | Default | Purpose | | -------------------------------- | -------- | ------- | ------------------------------------------ | | `friction` | number | 1 | Drag resistance (higher = slower response) | | `leftThreshold` | number | half | Distance to auto-open left panel | | `rightThreshold` | number | half | Distance to auto-open right panel | | `overshootLeft` | boolean | true | Allow pulling past left panel width | | `overshootRight` | boolean | true | Allow pulling past right panel width | | `overshootFriction` | number | 1 | Resistance during overshoot (try 8+) | | `dragOffsetFromLeftEdge` | number | 10 | Min drag distance to start left swipe | | `dragOffsetFromRightEdge` | number | 10 | Min drag distance to start right swipe | | `enableTrackpadTwoFingerGesture` | boolean | false | iPad trackpad two-finger swiping | | `renderLeftActions` | function | -- | Render left action panel | | `renderRightActions` | function | -- | Render right action panel | | `onSwipeableOpen` | callback | -- | Panel fully opened | | `onSwipeableClose` | callback | -- | Panel fully closed | | `onSwipeableWillOpen` | callback | -- | Open animation starts | | `onSwipeableWillClose` | callback | -- | Close animation starts | ### Ref Methods | Method | Purpose | | ------------- | -------------------------------- | | `close()` | Close swipeable programmatically | | `openLeft()` | Open left panel | | `openRight()` | Open right panel | | `reset()` | Reset state without animation | -
SKILL.md 16.9 KB
--- name: mobile-animation-gesture-handler description: React Native Gesture Handler - gesture types, GestureDetector, gesture composition, state machine, platform-specific gestures, swipeable rows, hover gestures --- # React Native Gesture Handler Patterns > **Quick Guide:** Use Gesture Handler's v2 builder API (`Gesture.Pan()`, `Gesture.Tap()`, etc.) with `GestureDetector` for all touch interactions. Wrap your app root in `GestureHandlerRootView`. Compose gestures with `Gesture.Simultaneous()`, `Gesture.Race()`, and `Gesture.Exclusive()`. Use `onChange` (not `onUpdate`) when working with animation shared values -- `onChange` provides deltas (`changeX`), `onUpdate` provides cumulative values (`translationX`). Gesture callbacks are automatically workletized when your animation library is installed. --- <critical_requirements> ## CRITICAL: Before Using This Skill > **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants) **(You MUST wrap the app root in `GestureHandlerRootView` -- gestures will silently fail without it)** **(You MUST use `GestureDetector` with the builder API (`Gesture.Pan()`, etc.) -- NOT the legacy `PanGestureHandler` components)** **(You MUST use `onChange` for incremental shared value updates and `onUpdate` for cumulative values -- mixing them causes drift)** **(You MUST use `Gesture.Simultaneous()` for multi-touch interactions (pinch + pan) -- without it, only one gesture activates)** **(You MUST NOT nest `GestureDetector` components using different API styles (hooks vs builder) under the same root -- this causes undefined behavior)** </critical_requirements> --- **Auto-detection:** react-native-gesture-handler, GestureDetector, GestureHandlerRootView, Gesture.Pan, Gesture.Tap, Gesture.Pinch, Gesture.Rotation, Gesture.LongPress, Gesture.Fling, Gesture.Hover, Gesture.Simultaneous, Gesture.Race, Gesture.Exclusive, Swipeable, ReanimatedSwipeable, onBegin, onStart, onChange, onUpdate, onEnd, onFinalize **When to use:** - Adding pan, pinch, tap, rotation, long-press, fling, or hover gestures to React Native views - Composing multiple gestures on the same view (pinch-to-zoom + drag) - Building swipeable list rows with reveal actions - Replacing React Native's built-in Gesture Responder System (PanResponder) - Implementing gesture-driven animations with shared values - Adding hover interactions for iPad trackpad, desktop, or web targets **Key patterns covered:** - GestureDetector + builder API for all gesture types - Gesture composition: Simultaneous, Race, Exclusive - Gesture state machine and lifecycle callbacks - Shared value integration in gesture callbacks (onChange vs onUpdate) - ReanimatedSwipeable for swipeable list rows - Hover gesture for pointer devices (iPad trackpad, mouse, stylus) - Platform-specific gesture configuration (Android ripple, iOS haptics) - Cross-component gesture relations (requireToFail, simultaneousWith, block) **When NOT to use:** - Simple button taps (use `Pressable` or `TouchableOpacity` from React Native core) - Scroll-only interactions (use `ScrollView` or `FlatList` directly) - Web-only applications without React Native **Detailed Resources:** - [examples/core.md](examples/core.md) - GestureDetector setup, pan, tap, pinch, rotation gestures with shared values - [examples/composition.md](examples/composition.md) - Simultaneous, Race, Exclusive composition, cross-component relations - [examples/swipeable.md](examples/swipeable.md) - ReanimatedSwipeable rows, FlatList integration, action panels - [examples/advanced.md](examples/advanced.md) - Hover gesture, manual gesture control, platform-specific config - [reference.md](reference.md) - Decision frameworks, gesture type reference, state machine diagram --- <philosophy> ## Philosophy React Native Gesture Handler replaces the built-in Gesture Responder System with native-driven gesture recognition. The key advantage is that gestures are processed on the native thread, not JS -- so they remain responsive even when JS is busy. **Core principles:** 1. **Native-first** -- Gesture recognition runs natively; callbacks optionally run as worklets on the UI thread 2. **Declarative composition** -- Define gestures as objects, compose them with `Simultaneous`, `Race`, `Exclusive` 3. **State machine driven** -- Every gesture follows UNDETERMINED -> BEGAN -> ACTIVE -> END/FAILED/CANCELLED 4. **Builder API** -- Chain configuration methods: `Gesture.Pan().minDistance(10).onUpdate(handler)` 5. **One GestureDetector per gesture (or composed gesture)** -- Don't attach multiple unrelated gestures via separate nested detectors **Mental model:** Think of each gesture as a state machine that competes with other gestures for activation. Composition methods (`Simultaneous`, `Race`, `Exclusive`) define the competition rules. The gesture that wins transitions to ACTIVE; the rest FAIL or get CANCELLED. **v3 hooks API (beta):** RNGH v3 introduces a hooks-based API (`usePanGesture`, `useSimultaneousGestures`, etc.) that is cleaner but still in beta. The v2 builder API (`Gesture.Pan()`, `GestureDetector`) is the stable production API documented here. </philosophy> --- <patterns> ## Core Patterns ### Pattern 1: GestureHandlerRootView Setup Every app using Gesture Handler must wrap its root in `GestureHandlerRootView`. Without it, gestures silently fail -- no errors, just no recognition. ```typescript import { GestureHandlerRootView } from "react-native-gesture-handler"; export function App() { return ( <GestureHandlerRootView style={{ flex: 1 }}> <AppContent /> </GestureHandlerRootView> ); } ``` **Why good:** single root wrapper, `flex: 1` ensures full-screen coverage **Gotcha (Android modals):** React Native Modals on Android create a separate native view hierarchy. Wrap Modal content in its own `GestureHandlerRootView` -- gestures inside a Modal won't work otherwise. **Gotcha (native navigation):** When using a native navigation library (separate screen containers per screen), wrap each registered screen individually rather than a single app-level root. See [examples/core.md](examples/core.md) for the full setup pattern. --- ### Pattern 2: Pan Gesture with Shared Values The most common pattern: drag an element by tracking translation deltas. ```typescript const offset = useSharedValue(0); const pan = Gesture.Pan() .onChange((event) => { offset.value += event.changeX; // Incremental delta }) .onFinalize((event) => { offset.value = withDecay({ velocity: event.velocityX }); }); // Wrap with GestureDetector + Animated.View <GestureDetector gesture={pan}> <Animated.View style={animatedStyle} /> </GestureDetector> ``` **Why `onChange` over `onUpdate`:** `onChange` provides `changeX`/`changeY` (delta since last event) -- add it to an offset. `onUpdate` provides `translationX`/`translationY` (cumulative since gesture start) -- use it as an absolute position. Mixing them causes position drift. See [examples/core.md](examples/core.md) for complete pan, tap, pinch, and rotation examples. --- ### Pattern 3: Gesture Composition Compose multiple gestures on the same view with three strategies: ```typescript const pan = Gesture.Pan().onChange(/* ... */); const pinch = Gesture.Pinch().onUpdate(/* ... */); const rotation = Gesture.Rotation().onUpdate(/* ... */); // All three active simultaneously (photo viewer) const composed = Gesture.Simultaneous(pan, pinch, rotation); // First to activate wins, rest fail (swipe vs long-press) const racing = Gesture.Race(pan, longPress); // Priority order: first arg wins ties (double-tap beats single-tap) const exclusive = Gesture.Exclusive(doubleTap, singleTap); <GestureDetector gesture={composed}> <Animated.View /> </GestureDetector> ``` **Key rule:** Pass the composed gesture to a single `GestureDetector`. Do not nest multiple `GestureDetector` components for gestures that should interact -- use composition instead. See [examples/composition.md](examples/composition.md) for full photo viewer and tap disambiguation patterns. --- ### Pattern 4: Gesture State Machine Every gesture follows a state machine. Understanding it is critical for debugging gesture conflicts: ``` UNDETERMINED ──> BEGAN ──> ACTIVE ──> END ──> UNDETERMINED │ │ ├──> FAILED ─────────┘ │ │ └──> (ACTIVE) ──> CANCELLED ──> UNDETERMINED ``` **Lifecycle callbacks map to states:** | Callback | When it fires | | ------------ | ---------------------------------------------------------- | | `onBegin` | UNDETERMINED -> BEGAN (touch detected, not yet recognized) | | `onStart` | BEGAN -> ACTIVE (gesture recognized, meets criteria) | | `onUpdate` | While ACTIVE (cumulative: `translationX`, `scale`) | | `onChange` | While ACTIVE (incremental: `changeX`, `changeY`) | | `onEnd` | ACTIVE -> END (finger lifted normally) | | `onFinalize` | Fires for ANY terminal state (END, FAILED, CANCELLED) | **`onEnd` vs `onFinalize`:** Use `onEnd` for success-only cleanup (save position). Use `onFinalize` for guaranteed cleanup (reset state regardless of outcome). `onFinalize` receives a second `success` boolean parameter. See [reference.md](reference.md) for the complete state transition diagram. --- ### Pattern 5: Cross-Component Gesture Relations When gestures live on different components (parent scroll + child drag), use relation methods: ```typescript const childPan = Gesture.Pan(); const parentScroll = Gesture.Native(); // Wraps native ScrollView gesture // Child drag must fail before parent scroll activates parentScroll.requireExternalGestureToFail(childPan); // Or: both active simultaneously childPan.simultaneousWith(parentScroll); // Or: child blocks parent while active childPan.blocksExternalGesture(parentScroll); ``` **Key difference from composition:** `Gesture.Simultaneous()` composes gestures on the same component. `.simultaneousWith()` relates gestures across different components in the view hierarchy. See [examples/composition.md](examples/composition.md) for cross-component patterns. --- ### Pattern 6: ReanimatedSwipeable for List Rows Use `ReanimatedSwipeable` (not the legacy `Swipeable`) for swipeable list items with smooth 60fps animations. ```typescript import ReanimatedSwipeable from "react-native-gesture-handler/ReanimatedSwipeable"; const SWIPE_THRESHOLD = 40; const OVERSHOOT_FRICTION = 8; <ReanimatedSwipeable friction={2} rightThreshold={SWIPE_THRESHOLD} overshootFriction={OVERSHOOT_FRICTION} renderRightActions={renderRightActions} onSwipeableOpen={handleDelete} > <ListItemContent /> </ReanimatedSwipeable> ``` **Why ReanimatedSwipeable over Swipeable:** Rewritten with worklets for native-thread animations. The legacy `Swipeable` animates on JS thread and drops frames during heavy renders. See [examples/swipeable.md](examples/swipeable.md) for full implementation with action panels and FlatList integration. --- ### Pattern 7: Hover Gesture (Pointer Devices) Hover gesture detects mouse/stylus/trackpad hovering -- useful for iPad with trackpad, macOS, and web targets. Does not fire on phone touch. ```typescript const hover = Gesture.Hover() .onBegin(() => { isHovered.value = true; }) .onEnd(() => { isHovered.value = false; }); ``` **Platform support:** iOS (iPad with trackpad/mouse), macOS, web. Does NOT work on Android touch or iPhone touch. On iOS, the optional `hoverEffect` prop on `GestureDetector` provides system-level visual effects (highlight, lift, automatic). See [examples/advanced.md](examples/advanced.md) for hover with visual effects. </patterns> --- <decision_framework> ## Decision Framework ### Which Gesture Type? ``` What user interaction are you handling? ├─ Drag/move element → Gesture.Pan() ├─ Single tap → Gesture.Tap() ├─ Double tap → Gesture.Tap().numberOfTaps(2) ├─ Long press → Gesture.LongPress() ├─ Pinch to zoom → Gesture.Pinch() ├─ Two-finger rotate → Gesture.Rotation() ├─ Quick directional swipe → Gesture.Fling() ├─ Mouse/trackpad hover → Gesture.Hover() └─ Wrap a native gesture → Gesture.Native() (for ScrollView, etc.) ``` ### Which Composition? ``` How should multiple gestures interact? ├─ All active at once (pan + pinch + rotate in photo viewer) │ └─ Gesture.Simultaneous(gesture1, gesture2, ...) ├─ First to activate wins (swipe vs long-press on same view) │ └─ Gesture.Race(gesture1, gesture2, ...) ├─ Priority order (double-tap must beat single-tap) │ └─ Gesture.Exclusive(highPriority, lowPriority) └─ Gestures on DIFFERENT components (child drag vs parent scroll) ├─ Parent waits for child to fail → parent.requireExternalGestureToFail(child) ├─ Both active simultaneously → child.simultaneousWith(parent) └─ Child blocks parent → child.blocksExternalGesture(parent) ``` ### onChange vs onUpdate? ``` How are you using the gesture position data? ├─ Adding to an offset (shared value += delta) │ └─ Use onChange (provides changeX, changeY) ├─ Setting absolute position from gesture start │ └─ Use onUpdate (provides translationX, translationY) └─ Need both (rare) └─ Use onChange for offsets + onUpdate for display values ``` </decision_framework> --- <red_flags> ## RED FLAGS **High Priority Issues:** - Missing `GestureHandlerRootView` at app root -- gestures silently fail with no error message - Using legacy `PanGestureHandler`/`TapGestureHandler` components -- deprecated, use `Gesture.Pan()` + `GestureDetector` - Using `onUpdate` to add deltas to shared values -- `onUpdate` provides cumulative values, not deltas; use `onChange` for incremental updates - Nesting multiple `GestureDetector` components for gestures that should compose -- use `Gesture.Simultaneous()`/`Race()`/`Exclusive()` instead - Using `React.PanResponder` -- replaced entirely by Gesture Handler; PanResponder runs on JS thread and blocks the UI **Medium Priority Issues:** - Not specifying `minDistance` on `Gesture.Pan()` when coexisting with tap gestures -- pan activates immediately at 0px, stealing taps - Missing `onFinalize` cleanup -- `onEnd` only fires on success; cancelled/failed gestures skip it - Using `Swipeable` instead of `ReanimatedSwipeable` -- legacy component runs animations on JS thread - Creating new gesture instances inside render (not in useMemo or outside component) -- causes gesture to reset every render **Gotchas & Edge Cases:** - `Gesture.Exclusive(doubleTap, singleTap)` -- double-tap MUST be the first argument (higher priority) or it will never fire because single-tap activates first - Android modals need their own `GestureHandlerRootView` -- gestures in modals fail silently without it - `onChange` callback is NOT available in the v3 hooks API -- it was removed; use `onUpdate` with the `change*` properties on the event object instead - Gesture callbacks are automatically workletized when your animation library is installed -- don't manually add `"worklet"` directives unless you need them without the animation library - `Gesture.Native()` wraps platform-native gestures (ScrollView, FlatList) -- use it when you need to create relations between custom gestures and native scrolling - `onFinalize` receives `(event, success)` -- the `success` boolean tells you whether the gesture ended normally (END) or was cancelled/failed - Reusing the same gesture instance across multiple `GestureDetector` components causes undefined behavior -- create separate instances - iPad trackpad two-finger gestures require `enableTrackpadTwoFingerGesture` on both Pan and `ReanimatedSwipeable` - `hoverEffect` prop on GestureDetector only works on iOS 17.0+ and provides system-level visual effects - `Gesture.Fling()` only fires at the end of the fling, not continuously -- use `Gesture.Pan()` if you need continuous position tracking during a swipe </red_flags> --- <critical_reminders> ## CRITICAL REMINDERS > **All code must follow project conventions in CLAUDE.md** **(You MUST wrap the app root in `GestureHandlerRootView` -- gestures will silently fail without it)** **(You MUST use `GestureDetector` with the builder API (`Gesture.Pan()`, etc.) -- NOT the legacy `PanGestureHandler` components)** **(You MUST use `onChange` for incremental shared value updates and `onUpdate` for cumulative values -- mixing them causes drift)** **(You MUST use `Gesture.Simultaneous()` for multi-touch interactions (pinch + pan) -- without it, only one gesture activates)** **(You MUST NOT nest `GestureDetector` components using different API styles (hooks vs builder) under the same root -- this causes undefined behavior)** **Failure to follow these rules will cause silent gesture failures, animation drift, and broken multi-touch interactions.** </critical_reminders>
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.