diff --git a/components/SlideToConfirm.tsx b/components/SlideToConfirm.tsx index c40f32d..fb9195b 100644 --- a/components/SlideToConfirm.tsx +++ b/components/SlideToConfirm.tsx @@ -2,10 +2,12 @@ * SlideToConfirm — pure RN, no RNGH, no Reanimated. * PanResponder on handle + Animated(useNativeDriver:false). * - * Fix: live refs break stale closures — disabledRef + onConfirmRef - * updated on every render so PanResponder always reads current values. + * 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 } from 'react'; +import { useRef, useState } from 'react'; import { Animated, PanResponder, @@ -23,89 +25,132 @@ interface Props { color?: string; } -const HANDLE_SIZE = 56; -const THRESHOLD = 0.80; +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) { - const trackWidth = useRef(0); - const confirmed = useRef(false); - const translateX = useRef(new Animated.Value(0)).current; + // trackWidthRef — read inside PanResponder (no stale closure) + // trackWidthState — triggers re-render so labelOpacity interpolation updates + const trackWidthRef = useRef(0); + const [trackWidthState, setTrackWidthState] = useState(0); - // ── Live refs: break stale closures inside PanResponder ────────────────── + 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; // updated on every render + disabledRef.current = disabled; const onConfirmRef = useRef(onConfirm); - onConfirmRef.current = onConfirm; // updated on every render + onConfirmRef.current = onConfirm; const panResponder = useRef( PanResponder.create({ - onStartShouldSetPanResponder: () => !disabledRef.current, - // Horizontal-dominance check prevents ScrollView stealing vertical-ish drags on iOS + // Block gestures until layout has been measured + onStartShouldSetPanResponder: () => + !disabledRef.current && trackWidthRef.current > 0, + // Horizontal-dominance — prevents ScrollView stealing on iOS onMoveShouldSetPanResponder: (_, gs) => - !disabledRef.current && Math.abs(gs.dx) > Math.abs(gs.dy) * 2, + !disabledRef.current && + trackWidthRef.current > 0 && + Math.abs(gs.dx) > Math.abs(gs.dy) * 2, + onPanResponderGrant: () => { confirmed.current = false; - (translateX as any).setOffset((translateX as any)._value); - translateX.setValue(0); + // Stop any running spring before starting a new drag + translateX.stopAnimation(); + startPos.current = currentPos.current; }, + onPanResponderMove: (_, gs) => { - const maxX = Math.max(trackWidth.current - HANDLE_SIZE - 6, 1); - const clamped = Math.min(Math.max(gs.dx, 0), maxX); - translateX.setValue(clamped - (translateX as any)._offset); + 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; - (translateX as any).flattenOffset(); Animated.spring(translateX, { toValue: maxX, useNativeDriver: false, damping: 18, stiffness: 220, - }).start(); - onConfirmRef.current(); // always calls latest onConfirm + }).start(({ finished }) => { + if (finished) currentPos.current = maxX; + }); + onConfirmRef.current(); } }, + onPanResponderRelease: () => { - (translateX as any).flattenOffset(); if (!confirmed.current) { + currentPos.current = 0; Animated.spring(translateX, { toValue: 0, useNativeDriver: false, damping: 18, stiffness: 220, - }).start(); + }).start(({ finished }) => { + if (finished) currentPos.current = 0; + }); } }, + onPanResponderTerminate: () => { - (translateX as any).flattenOffset(); if (!confirmed.current) { + currentPos.current = 0; Animated.spring(translateX, { toValue: 0, useNativeDriver: false, - }).start(); + }).start(({ finished }) => { + if (finished) currentPos.current = 0; + }); } }, }) ).current; - const maxXForOpacity = Math.max(trackWidth.current - HANDLE_SIZE - 6, 1); + const maxXForOpacity = Math.max(trackWidthState - HANDLE_SIZE - HANDLE_MARGIN * 2, 1); const labelOpacity = translateX.interpolate({ - inputRange: [0, maxXForOpacity > 0 ? maxXForOpacity * 0.6 : 1], + inputRange: [0, maxXForOpacity * 0.6], outputRange: [1, 0], extrapolate: 'clamp', }); return ( { trackWidth.current = e.nativeEvent.layout.width; }} + style={[ + styles.track, + disabled && styles.trackDisabled, + color && !disabled ? { backgroundColor: color } : undefined, + style, + ]} + onLayout={e => { + const w = e.nativeEvent.layout.width; + trackWidthRef.current = w; // PanResponder reads this + setTrackWidthState(w); // triggers re-render for labelOpacity + }} > - + {label} @@ -138,7 +183,7 @@ const styles = StyleSheet.create({ backgroundColor: '#fff', alignItems: 'center', justifyContent: 'center', - marginLeft: 3, + marginLeft: HANDLE_MARGIN, shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.18,