From bf1e36f9e70c652a2e8ac688be746caa76d91e4e Mon Sep 17 00:00:00 2001 From: Nemo Date: Wed, 25 Mar 2026 06:12:51 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20SlideToConfirm=20v3=20=E2=80=94=20pageX?= =?UTF-8?q?=20tracking,=20tip=20bounce,=20check=20icon,=20threshold=20on?= =?UTF-8?q?=20release?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/SlideToConfirm.tsx | 216 ++++++++++++++++++++-------------- 1 file changed, 126 insertions(+), 90 deletions(-) diff --git a/components/SlideToConfirm.tsx b/components/SlideToConfirm.tsx index fb9195b..e512437 100644 --- a/components/SlideToConfirm.tsx +++ b/components/SlideToConfirm.tsx @@ -1,13 +1,16 @@ /** * SlideToConfirm — pure RN, no RNGH, no Reanimated. - * PanResponder on handle + Animated(useNativeDriver:false). + * Approach ported from rn-slide-to-confirm (PanResponder + pageX tracking). * - * 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 + * 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 */ -import { useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Animated, PanResponder, @@ -25,136 +28,161 @@ interface Props { color?: string; } -const HANDLE_SIZE = 56; -const HANDLE_MARGIN = 3; -const THRESHOLD = 0.80; +const HANDLE_SIZE = 56; +const HANDLE_PADDING = 3; +const THRESHOLD = 0.80; +const TIP_DISTANCE = 30; +const TIP_DURATION = 280; +const SNAP_DURATION = 280; -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); +export function SlideToConfirm({ + onConfirm, + label = 'Slide to confirm', + disabled = false, + style, + color, +}: Props) { + // Track width — ref for PanResponder (no stale closure), state for re-render + const trackWidthRef = useRef(0); const [trackWidthState, setTrackWidthState] = useState(0); - const confirmed = useRef(false); - const translateX = useRef(new Animated.Value(0)).current; + const pan = useRef(new Animated.Value(0)).current; + const startPageX = useRef(0); + const confirmed = useRef(false); + const [isConfirmed, setIsConfirmed] = useState(false); - // 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 ────────────────────────────────────── + // Live refs — break stale closures 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 snapToEnd = () => { + const maxX = trackWidthRef.current - HANDLE_SIZE - HANDLE_PADDING * 2; + Animated.spring(pan, { + toValue: maxX, + useNativeDriver: false, + damping: 18, + stiffness: 220, + }).start(({ finished }) => { + if (finished) { + setIsConfirmed(true); + onConfirmRef.current(); + } + }); + }; + + const snapBack = () => { + Animated.timing(pan, { + toValue: 0, + duration: SNAP_DURATION, + useNativeDriver: false, + }).start(({ finished }) => { + if (finished && !disabledRef.current) runTip(); + }); + }; + const panResponder = useRef( PanResponder.create({ - // Block gestures until layout has been measured + // Block until layout 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, + Math.abs(gs.dx) > Math.abs(gs.dy) * 1.5, - onPanResponderGrant: () => { + onPanResponderGrant: (e) => { confirmed.current = false; - // Stop any running spring before starting a new drag - translateX.stopAnimation(); - startPos.current = currentPos.current; + pan.stopAnimation(); + startPageX.current = e.nativeEvent.pageX; }, - 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; + 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); - 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(); - } + pan.setValue(clamped); }, - 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; - }); + 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; + snapToEnd(); + } else { + snapBack(); } }, onPanResponderTerminate: () => { - if (!confirmed.current) { - currentPos.current = 0; - Animated.spring(translateX, { - toValue: 0, - useNativeDriver: false, - }).start(({ finished }) => { - if (finished) currentPos.current = 0; - }); - } + if (!confirmed.current) snapBack(); }, }) ).current; - const maxXForOpacity = Math.max(trackWidthState - HANDLE_SIZE - HANDLE_MARGIN * 2, 1); - const labelOpacity = translateX.interpolate({ - inputRange: [0, maxXForOpacity * 0.6], + const maxXForFade = Math.max(trackWidthState - HANDLE_SIZE - HANDLE_PADDING * 2, 1); + const labelOpacity = pan.interpolate({ + inputRange: [0, maxXForFade * 0.6], outputRange: [1, 0], extrapolate: 'clamp', }); + const checkOpacity = pan.interpolate({ + inputRange: [maxXForFade * 0.6, maxXForFade], + outputRange: [0, 1], + extrapolate: 'clamp', + }); + + const trackBg = color && !disabled ? color : undefined; return ( { const w = e.nativeEvent.layout.width; - trackWidthRef.current = w; // PanResponder reads this - setTrackWidthState(w); // triggers re-render for labelOpacity + trackWidthRef.current = w; + setTrackWidthState(w); }} > - + {/* Label — fades out as handle moves right */} + {label} + {/* "Confirmed" label — fades in near end */} + + Confirmed ✓ + + + {/* Sliding handle */} - + {isConfirmed + ? + : + } ); @@ -176,6 +204,10 @@ const styles = StyleSheet.create({ fontWeight: '600', letterSpacing: 0.3, }, + confirmedLabel: { + color: '#fff', + fontWeight: '700', + }, handle: { width: HANDLE_SIZE, height: HANDLE_SIZE, @@ -183,7 +215,7 @@ const styles = StyleSheet.create({ backgroundColor: '#fff', alignItems: 'center', justifyContent: 'center', - marginLeft: HANDLE_MARGIN, + marginLeft: HANDLE_PADDING, shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.18, @@ -191,10 +223,14 @@ const styles = StyleSheet.create({ elevation: 4, }, handleDisabled: { backgroundColor: '#E2E8F0' }, - arrow: { + icon: { fontSize: 28, color: '#0891B2', fontWeight: '800', lineHeight: 32, }, + checkIcon: { + color: '#059669', + fontSize: 24, + }, });