/** * 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 */ import { useEffect, 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_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) { // Track width — ref for PanResponder (no stale closure), state for re-render 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); // 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 until layout measured onStartShouldSetPanResponder: () => !disabledRef.current && trackWidthRef.current > 0, onMoveShouldSetPanResponder: (_, gs) => !disabledRef.current && trackWidthRef.current > 0 && Math.abs(gs.dx) > Math.abs(gs.dy) * 1.5, onPanResponderGrant: (e) => { confirmed.current = false; pan.stopAnimation(); startPageX.current = e.nativeEvent.pageX; }, 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); }, 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) snapBack(); }, }) ).current; 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; setTrackWidthState(w); }} > {/* Label — fades out as handle moves right */} {label} {/* "Confirmed" label — fades in near end */} Confirmed ✓ {/* Sliding handle */} {isConfirmed ? : } ); } 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, }, confirmedLabel: { color: '#fff', fontWeight: '700', }, handle: { width: HANDLE_SIZE, height: HANDLE_SIZE, borderRadius: HANDLE_SIZE / 2, backgroundColor: '#fff', alignItems: 'center', justifyContent: 'center', marginLeft: HANDLE_PADDING, shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.18, shadowRadius: 4, elevation: 4, }, handleDisabled: { backgroundColor: '#E2E8F0' }, icon: { fontSize: 28, color: '#0891B2', fontWeight: '800', lineHeight: 32, }, checkIcon: { color: '#059669', fontSize: 24, }, });