From harness-claude
Implements touch gestures with React Native Gesture Handler v2 for swipe, pan, pinch, and long press. Useful for draggable cards, swipe-to-delete, bottom sheets, and custom navigation in React Native apps.
npx claudepluginhub intense-visions/harness-engineering --plugin harness-claudeThis skill uses the workspace's default tool permissions.
> Implement touch gestures with React Native Gesture Handler for swipe, pan, pinch, and long press interactions
Implements SwiftUI gestures (tap, drag, long press, magnification, rotation); composes with .simultaneously/.sequenced/.exclusively; manages @GestureState; debugs conflicts; adds VoiceOver accessibility.
Designs intuitive gesture interactions for touch and pointer devices, covering core gestures, feedback rules, conflict resolution, accessibility, and platform best practices.
Provides React Native patterns for StyleSheet styling, Flexbox layouts, React Navigation, Gesture Handler, and Reanimated 3 animations in cross-platform mobile apps.
Share bugs, ideas, or general feedback.
Implement touch gestures with React Native Gesture Handler for swipe, pan, pinch, and long press interactions
Gesture objects composed with GestureDetector, replacing the old component-based API.npx expo install react-native-gesture-handler react-native-reanimated
Wrap your app root with GestureHandlerRootView:
import { GestureHandlerRootView } from 'react-native-gesture-handler';
export default function App() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<Navigation />
</GestureHandlerRootView>
);
}
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, { useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';
function DraggableCard() {
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
const savedX = useSharedValue(0);
const savedY = useSharedValue(0);
const pan = Gesture.Pan()
.onStart(() => {
savedX.value = translateX.value;
savedY.value = translateY.value;
})
.onUpdate((event) => {
translateX.value = savedX.value + event.translationX;
translateY.value = savedY.value + event.translationY;
})
.onEnd(() => {
translateX.value = withSpring(0);
translateY.value = withSpring(0);
});
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }, { translateY: translateY.value }],
}));
return (
<GestureDetector gesture={pan}>
<Animated.View style={[styles.card, animatedStyle]}>
<Text>Drag me</Text>
</Animated.View>
</GestureDetector>
);
}
function SwipeableRow({ onDelete, children }: Props) {
const translateX = useSharedValue(0);
const DELETE_THRESHOLD = -100;
const pan = Gesture.Pan()
.activeOffsetX([-10, 10]) // Activate only for horizontal movement
.onUpdate((e) => {
translateX.value = Math.min(0, e.translationX); // Only swipe left
})
.onEnd(() => {
if (translateX.value < DELETE_THRESHOLD) {
translateX.value = withTiming(-300, {}, () => {
runOnJS(onDelete)();
});
} else {
translateX.value = withSpring(0);
}
});
const style = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }],
}));
return (
<GestureDetector gesture={pan}>
<Animated.View style={style}>{children}</Animated.View>
</GestureDetector>
);
}
function ZoomableImage({ source }: { source: ImageSourcePropType }) {
const scale = useSharedValue(1);
const savedScale = useSharedValue(1);
const pinch = Gesture.Pinch()
.onUpdate((e) => {
scale.value = savedScale.value * e.scale;
})
.onEnd(() => {
savedScale.value = scale.value;
if (scale.value < 1) {
scale.value = withSpring(1);
savedScale.value = 1;
}
});
const style = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
return (
<GestureDetector gesture={pinch}>
<Animated.Image source={source} style={[styles.image, style]} />
</GestureDetector>
);
}
Gesture.Simultaneous() for gestures that should work together (pan + pinch) and Gesture.Exclusive() for competing gestures (tap vs. long press).const pan = Gesture.Pan().onUpdate(/* ... */);
const pinch = Gesture.Pinch().onUpdate(/* ... */);
// Both gestures active at the same time
const combined = Gesture.Simultaneous(pan, pinch);
// Only one gesture wins
const tap = Gesture.Tap().onEnd(handleTap);
const longPress = Gesture.LongPress().minDuration(500).onEnd(handleLongPress);
const exclusive = Gesture.Exclusive(longPress, tap); // Long press takes priority
return <GestureDetector gesture={combined}>{/* ... */}</GestureDetector>;
runOnJS to call JavaScript functions from gesture worklets. Gesture callbacks run on the UI thread. To call React state setters or navigation, wrap them with runOnJS.const tap = Gesture.Tap().onEnd(() => {
runOnJS(navigation.navigate)('Details');
});
const pan = Gesture.Pan()
.minDistance(10) // Minimum pixels before activation
.activeOffsetX([-20, 20]) // Only activate for horizontal movement
.failOffsetY([-5, 5]); // Fail if vertical movement exceeds threshold
RNGH v2 vs. v1: The v2 declarative API (Gesture.Pan()) replaces v1's component API (<PanGestureHandler>). v2 is composable, works directly with Reanimated worklets, and supports gesture composition natively.
UI thread execution: RNGH gesture callbacks and Reanimated worklets run on the native UI thread, not the JavaScript thread. This means gestures remain responsive even if JavaScript is busy. Always use shared values (useSharedValue) instead of React state for gesture-driven animations.
Gesture states: Each gesture transitions through states: UNDETERMINED -> BEGAN -> ACTIVE -> END (or FAILED/CANCELLED). Use onStart (BEGAN), onUpdate (ACTIVE), and onEnd (END) to respond at each phase.
Common mistakes:
GestureHandlerRootView at the app root (gestures silently fail)runOnJShttps://docs.swmansion.com/react-native-gesture-handler/