import { useState, type ReactNode } from 'react'; import { StyleSheet, View, type LayoutChangeEvent } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { Gesture, GestureDetector, type GestureType } from 'react-native-gesture-handler'; import Animated, { runOnJS, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; import { useColors } from '@/theme/themed'; import { motion } from '@/theme/motion'; import { playHaptic } from '@/lib/haptics'; type IconName = keyof typeof Ionicons.glyphMap; const SWIPE_ACTIVE_OFFSET_X = 10; // Scroll-slop-sized: at 30 every vertical drag starting on a row had to travel // 30px before the pan failed and the surrounding scrollable could win. const SWIPE_FAIL_OFFSET_Y = 12; export interface SwipeAction { icon: IconName; /** Background of the revealed action lane. */ color: string; iconColor?: string; onCommit: () => void; } interface SwipeableRowProps { /** Revealed on the LEFT as the row is dragged right (a rightward swipe). */ swipeRight?: SwipeAction; /** Revealed on the RIGHT as the row is dragged left (a leftward swipe). */ swipeLeft?: SwipeAction; /** * Optional vertical gesture (e.g. a hold-to-drag reorder) raced against the * horizontal swipe in the SAME detector — composing here avoids the nested * GestureDetectors that broke continuous hold-and-drag. */ dragGesture?: GestureType; enabled?: boolean; children: ReactNode; } /** * Horizontally swipeable row. Translation is clamped to ±width/2; a haptic tick * fires when crossing the ±width/4 "arm" point in either direction (arming to * commit, or backing off). Releasing past the arm point runs the matching * action; otherwise the row springs back. Vertical drags fall through so the row * still scrolls / lets a parent sheet pan. */ export function SwipeableRow({ swipeRight, swipeLeft, dragGesture, enabled = true, children, }: SwipeableRowProps) { const colors = useColors(); const tx = useSharedValue(0); const armed = useSharedValue(false); const [rowWidth, setRowWidth] = useState(0); const max = rowWidth / 2; const arm = rowWidth / 4; const hasRight = !!swipeRight; const hasLeft = !!swipeLeft; const onLayout = (e: LayoutChangeEvent) => setRowWidth(e.nativeEvent.layout.width); const onCommit = (direction: 'right' | 'left') => { if (direction === 'right') swipeRight?.onCommit(); else swipeLeft?.onCommit(); playHaptic('confirm'); }; const pan = Gesture.Pan() .enabled(enabled && rowWidth > 0 && (hasRight || hasLeft)) .activeOffsetX([-SWIPE_ACTIVE_OFFSET_X, SWIPE_ACTIVE_OFFSET_X]) .failOffsetY([-SWIPE_FAIL_OFFSET_Y, SWIPE_FAIL_OFFSET_Y]) .onUpdate((e) => { let t = e.translationX; if (t > 0 && !hasRight) t = 0; if (t < 0 && !hasLeft) t = 0; t = Math.max(-max, Math.min(max, t)); tx.value = t; const nowArmed = Math.abs(t) >= arm; if (nowArmed !== armed.value) { armed.value = nowArmed; runOnJS(playHaptic)('threshold'); } }) .onEnd(() => { const t = tx.value; if (t >= arm && hasRight) runOnJS(onCommit)('right'); else if (t <= -arm && hasLeft) runOnJS(onCommit)('left'); armed.value = false; tx.value = withTiming(0, motion.quick); }); // Race so a horizontal swipe and a (long-press) vertical drag never fight: // whichever activates first wins and cancels the other. const gesture = dragGesture ? Gesture.Race(dragGesture, pan) : pan; const contentStyle = useAnimatedStyle(() => ({ transform: [{ translateX: tx.value }] })); const leftLaneStyle = useAnimatedStyle(() => ({ opacity: tx.value > 1 ? 1 : 0 })); const rightLaneStyle = useAnimatedStyle(() => ({ opacity: tx.value < -1 ? 1 : 0 })); return ( {swipeRight ? ( ) : null} {swipeLeft ? ( ) : null} {children} ); } const styles = StyleSheet.create({ wrap: { position: 'relative', overflow: 'hidden', }, lane: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 24, }, laneLeft: { justifyContent: 'flex-start', }, laneRight: { justifyContent: 'flex-end', }, });