/** * SlideToConfirm — pure RN, no RNGH, no Reanimated. * PanResponder on handle + Animated(useNativeDriver:false). * * Fix history: * - v1: stale closure fix — disabledRef + onConfirmRef * - v2: trackWidth=0 snap bug — trackWidthRef+State, startPos/currentPos refs, * eliminated _value/_offset private API, layout guard on gesture start */ import { useRef, useState } from 'react'; import { Animated, PanResponder, StyleSheet, Text, View, ViewStyle, } from 'react-native'; interface Props { onConfirm: () => void; label?: string; disabled?: boolean; style?: ViewStyle; color?: string; } const HANDLE_SIZE = 56; const HANDLE_MARGIN = 3; const THRESHOLD = 0.80; export function SlideToConfirm({ onConfirm, label = 'Slide to confirm', disabled = false, style, color }: Props) { // trackWidthRef — read inside PanResponder (no stale closure) // trackWidthState — triggers re-render so labelOpacity interpolation updates const trackWidthRef = useRef(0); const [trackWidthState, setTrackWidthState] = useState(0); const confirmed = useRef(false); const translateX = useRef(new Animated.Value(0)).current; // Explicit position tracking — avoids private _value/_offset API const startPos = useRef(0); // handle position when gesture starts const currentPos = useRef(0); // handle position during/after gesture // ── Live refs: break stale closures ────────────────────────────────────── const disabledRef = useRef(disabled); disabledRef.current = disabled; const onConfirmRef = useRef(onConfirm); onConfirmRef.current = onConfirm; const panResponder = useRef( PanResponder.create({ // Block gestures until layout has been measured onStartShouldSetPanResponder: () => !disabledRef.current && trackWidthRef.current > 0, // Horizontal-dominance — prevents ScrollView stealing on iOS onMoveShouldSetPanResponder: (_, gs) => !disabledRef.current && trackWidthRef.current > 0 && Math.abs(gs.dx) > Math.abs(gs.dy) * 2, onPanResponderGrant: () => { confirmed.current = false; // Stop any running spring before starting a new drag translateX.stopAnimation(); startPos.current = currentPos.current; }, onPanResponderMove: (_, gs) => { const maxX = trackWidthRef.current - HANDLE_SIZE - HANDLE_MARGIN * 2; if (maxX <= 0) return; // safety — layout not ready const raw = startPos.current + gs.dx; const clamped = Math.min(Math.max(raw, 0), maxX); currentPos.current = clamped; translateX.setValue(clamped); if (!confirmed.current && clamped / maxX >= THRESHOLD) { confirmed.current = true; Animated.spring(translateX, { toValue: maxX, useNativeDriver: false, damping: 18, stiffness: 220, }).start(({ finished }) => { if (finished) currentPos.current = maxX; }); onConfirmRef.current(); } }, onPanResponderRelease: () => { if (!confirmed.current) { currentPos.current = 0; Animated.spring(translateX, { toValue: 0, useNativeDriver: false, damping: 18, stiffness: 220, }).start(({ finished }) => { if (finished) currentPos.current = 0; }); } }, onPanResponderTerminate: () => { if (!confirmed.current) { currentPos.current = 0; Animated.spring(translateX, { toValue: 0, useNativeDriver: false, }).start(({ finished }) => { if (finished) currentPos.current = 0; }); } }, }) ).current; const maxXForOpacity = Math.max(trackWidthState - HANDLE_SIZE - HANDLE_MARGIN * 2, 1); const labelOpacity = translateX.interpolate({ inputRange: [0, maxXForOpacity * 0.6], outputRange: [1, 0], extrapolate: 'clamp', }); return ( { const w = e.nativeEvent.layout.width; trackWidthRef.current = w; // PanResponder reads this setTrackWidthState(w); // triggers re-render for labelOpacity }} > {label} ); } const styles = StyleSheet.create({ track: { height: 60, backgroundColor: '#0E7490', borderRadius: 30, justifyContent: 'center', overflow: 'hidden', }, trackDisabled: { backgroundColor: '#94A3B8' }, labelWrap: { alignItems: 'center', justifyContent: 'center' }, label: { color: 'rgba(255,255,255,0.85)', fontSize: 15, fontWeight: '600', letterSpacing: 0.3, }, handle: { width: HANDLE_SIZE, height: HANDLE_SIZE, borderRadius: HANDLE_SIZE / 2, backgroundColor: '#fff', alignItems: 'center', justifyContent: 'center', marginLeft: HANDLE_MARGIN, shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.18, shadowRadius: 4, elevation: 4, }, handleDisabled: { backgroundColor: '#E2E8F0' }, arrow: { fontSize: 28, color: '#0891B2', fontWeight: '800', lineHeight: 32, }, });