From a3d40cd796fca95164ffcc12fc6ef7487bfe090a Mon Sep 17 00:00:00 2001 From: Nemo Date: Wed, 25 Mar 2026 06:24:13 +0800 Subject: [PATCH] fix: SlideToConfirm use Animated.event + useNativeDriver:true for real-time finger tracking --- components/SlideToConfirm.tsx | 149 +++++++++++++++++++--------------- 1 file changed, 82 insertions(+), 67 deletions(-) diff --git a/components/SlideToConfirm.tsx b/components/SlideToConfirm.tsx index e512437..a48d75e 100644 --- a/components/SlideToConfirm.tsx +++ b/components/SlideToConfirm.tsx @@ -1,14 +1,9 @@ /** * SlideToConfirm — pure RN, no RNGH, no Reanimated. - * Approach ported from rn-slide-to-confirm (PanResponder + pageX tracking). * - * Features: - * - Tip bounce animation on mount (hints it's slideable) - * - Smooth finger tracking via pageX - startPoint - * - Threshold check on release (not mid-drag) - * - Chevron → Checkmark icon on confirm - * - disabledRef + onConfirmRef break stale closures - * - trackWidthRef + trackWidthState: layout guard before gesture + * Key fix: Animated.event pipes gesture data directly to native thread + * (no JS bridge delay) — this is what makes the handle actually track the finger. + * useNativeDriver: true on all animations for 60fps smoothness. */ import { useEffect, useRef, useState } from 'react'; import { @@ -31,9 +26,9 @@ interface Props { const HANDLE_SIZE = 56; const HANDLE_PADDING = 3; const THRESHOLD = 0.80; -const TIP_DISTANCE = 30; -const TIP_DURATION = 280; -const SNAP_DURATION = 280; +const TIP_DISTANCE = 28; +const TIP_DURATION = 260; +const SNAP_DURATION = 260; export function SlideToConfirm({ onConfirm, @@ -42,40 +37,30 @@ export function SlideToConfirm({ style, color, }: Props) { - // Track width — ref for PanResponder (no stale closure), state for re-render + // trackWidthRef — read inside PanResponder callbacks (no stale closure) + // trackWidthState — triggers re-render so opacity interpolation updates const trackWidthRef = useRef(0); const [trackWidthState, setTrackWidthState] = useState(0); - const pan = useRef(new Animated.Value(0)).current; - const startPageX = useRef(0); - const confirmed = useRef(false); - const [isConfirmed, setIsConfirmed] = useState(false); + // pan drives all animation — useNativeDriver: true for all animations + const pan = useRef(new Animated.Value(0)).current; - // Live refs — break stale closures + const [isConfirmed, setIsConfirmed] = useState(false); + const confirmedRef = useRef(false); + + // Live refs — break stale closures inside PanResponder const disabledRef = useRef(disabled); disabledRef.current = disabled; const onConfirmRef = useRef(onConfirm); onConfirmRef.current = onConfirm; - // Tip bounce on mount - const runTip = () => { - Animated.sequence([ - Animated.timing(pan, { toValue: TIP_DISTANCE, duration: TIP_DURATION, useNativeDriver: false }), - Animated.timing(pan, { toValue: 0, duration: TIP_DURATION, useNativeDriver: false }), - ]).start(); - }; - - useEffect(() => { - // Wait for layout then bounce hint - const t = setTimeout(runTip, 600); - return () => clearTimeout(t); - }, []); + const getMaxX = () => + Math.max(trackWidthRef.current - HANDLE_SIZE - HANDLE_PADDING * 2, 1); const snapToEnd = () => { - const maxX = trackWidthRef.current - HANDLE_SIZE - HANDLE_PADDING * 2; Animated.spring(pan, { - toValue: maxX, - useNativeDriver: false, + toValue: getMaxX(), + useNativeDriver: true, damping: 18, stiffness: 220, }).start(({ finished }) => { @@ -90,44 +75,53 @@ export function SlideToConfirm({ Animated.timing(pan, { toValue: 0, duration: SNAP_DURATION, - useNativeDriver: false, + useNativeDriver: true, }).start(({ finished }) => { if (finished && !disabledRef.current) runTip(); }); }; + const runTip = () => { + if (disabledRef.current) return; + Animated.sequence([ + Animated.timing(pan, { toValue: TIP_DISTANCE, duration: TIP_DURATION, useNativeDriver: true }), + Animated.timing(pan, { toValue: 0, duration: TIP_DURATION, useNativeDriver: true }), + ]).start(); + }; + + useEffect(() => { + const t = setTimeout(runTip, 700); + return () => clearTimeout(t); + }, []); + const panResponder = useRef( PanResponder.create({ - // Block until layout measured + // Claim gesture on touch start — ensures dx=0 at grant, no jump onStartShouldSetPanResponder: () => !disabledRef.current && trackWidthRef.current > 0, + // Also claim on move (horizontal dominant) as fallback onMoveShouldSetPanResponder: (_, gs) => !disabledRef.current && trackWidthRef.current > 0 && - Math.abs(gs.dx) > Math.abs(gs.dy) * 1.5, + Math.abs(gs.dx) > Math.abs(gs.dy), - onPanResponderGrant: (e) => { - confirmed.current = false; + onPanResponderGrant: () => { + confirmedRef.current = false; pan.stopAnimation(); - startPageX.current = e.nativeEvent.pageX; + pan.setValue(0); // reset — Animated.event maps dx from 0 }, - onPanResponderMove: (e) => { - const maxX = trackWidthRef.current - HANDLE_SIZE - HANDLE_PADDING * 2; - if (maxX <= 0) return; - const raw = e.nativeEvent.pageX - startPageX.current; - const clamped = Math.min(Math.max(raw, 0), maxX); - pan.setValue(clamped); - }, + // Animated.event pipes dx directly to native thread — true 1:1 finger tracking + onPanResponderMove: Animated.event( + [null, { dx: pan }], + { useNativeDriver: true } + ), - onPanResponderRelease: (e) => { - const maxX = trackWidthRef.current - HANDLE_SIZE - HANDLE_PADDING * 2; - if (maxX <= 0) return; - const raw = e.nativeEvent.pageX - startPageX.current; - const clamped = Math.min(Math.max(raw, 0), maxX); - - if (clamped / maxX >= THRESHOLD) { - confirmed.current = true; + onPanResponderRelease: (_, gs) => { + const maxX = getMaxX(); + const clamped = Math.min(Math.max(gs.dx, 0), maxX); + if (!confirmedRef.current && clamped / maxX >= THRESHOLD) { + confirmedRef.current = true; snapToEnd(); } else { snapBack(); @@ -135,47 +129,68 @@ export function SlideToConfirm({ }, onPanResponderTerminate: () => { - if (!confirmed.current) snapBack(); + if (!confirmedRef.current) snapBack(); }, }) ).current; - const maxXForFade = Math.max(trackWidthState - HANDLE_SIZE - HANDLE_PADDING * 2, 1); + const maxX = Math.max(trackWidthState - HANDLE_SIZE - HANDLE_PADDING * 2, 1); const labelOpacity = pan.interpolate({ - inputRange: [0, maxXForFade * 0.6], + inputRange: [0, maxX * 0.5], outputRange: [1, 0], extrapolate: 'clamp', }); const checkOpacity = pan.interpolate({ - inputRange: [maxXForFade * 0.6, maxXForFade], + inputRange: [maxX * 0.6, maxX], outputRange: [0, 1], extrapolate: 'clamp', }); + // Clamp translateX visually so handle doesn't overshoot track + const clampedX = pan.interpolate({ + inputRange: [0, maxX], + outputRange: [0, maxX], + extrapolate: 'clamp', + }); - const trackBg = color && !disabled ? color : undefined; + const trackColor = color && !disabled ? color : undefined; return ( { const w = e.nativeEvent.layout.width; trackWidthRef.current = w; setTrackWidthState(w); }} > - {/* Label — fades out as handle moves right */} - + {/* "Slide to confirm" label — fades out as handle moves right */} + {label} - {/* "Confirmed" label — fades in near end */} - + {/* "Confirmed ✓" label — fades in near end */} + Confirmed ✓ - {/* Sliding handle */} + {/* Sliding handle — driven by Animated.event (native thread) */} @@ -197,7 +212,7 @@ const styles = StyleSheet.create({ overflow: 'hidden', }, trackDisabled: { backgroundColor: '#94A3B8' }, - labelWrap: { alignItems: 'center', justifyContent: 'center' }, + center: { alignItems: 'center', justifyContent: 'center' }, label: { color: 'rgba(255,255,255,0.85)', fontSize: 15,