diff --git a/components/SlideToConfirm.tsx b/components/SlideToConfirm.tsx index 5f191f2..4b6d3ab 100644 --- a/components/SlideToConfirm.tsx +++ b/components/SlideToConfirm.tsx @@ -1,125 +1,123 @@ -import { useRef, useState } from 'react'; -import { View, Text, Animated, StyleSheet } from 'react-native'; - -const TRACK_HEIGHT = 60; -const HANDLE_SIZE = 48; -const PADDING = 6; -const THRESHOLD = 0.82; +import { useCallback } from 'react'; +import { StyleSheet, Text, View, ViewStyle } from 'react-native'; +import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler'; +import Animated, { runOnJS, useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated'; interface Props { + onConfirm: () => void; label?: string; color?: string; - onConfirm: () => void; + threshold?: number; disabled?: boolean; + style?: ViewStyle; } -export function SlideToConfirm({ label = 'Slide to confirm', color = '#059669', onConfirm, disabled = false }: Props) { - const [trackW, setTrackW] = useState(0); - const [done, setDone] = useState(false); - const translateX = useRef(new Animated.Value(0)).current; - const startX = useRef(0); - const currentX = useRef(0); +const TRACK_HEIGHT = 60; +const HANDLE_SIZE = 48; +const TRACK_PADDING = 5; - const maxX = Math.max(trackW - HANDLE_SIZE - PADDING * 2, 1); +export function SlideToConfirm({ + onConfirm, + label = 'Slide to confirm', + color = '#059669', + threshold = 0.82, + disabled = false, + style, +}: Props) { + const translateX = useSharedValue(0); + const maxX = useSharedValue(200); // updated on layout - const onTouchStart = (e: any) => { - if (disabled || done) return; - startX.current = e.nativeEvent.pageX; - }; + const triggerConfirm = useCallback(() => { onConfirm(); }, [onConfirm]); - const onTouchMove = (e: any) => { - if (disabled || done) return; - const dx = e.nativeEvent.pageX - startX.current; - const x = Math.max(0, Math.min(dx, maxX)); - currentX.current = x; - translateX.setValue(x); - }; + const pan = Gesture.Pan() + .enabled(!disabled) + .minDistance(0) + .activeOffsetX([-5, 5]) + .onUpdate((e) => { + 'worklet'; + translateX.value = Math.max(0, Math.min(e.translationX, maxX.value)); + }) + .onEnd(() => { + 'worklet'; + if (translateX.value >= maxX.value * threshold) { + translateX.value = withSpring(maxX.value, { damping: 20, stiffness: 200, mass: 0.8 }); + runOnJS(triggerConfirm)(); + } else { + translateX.value = withSpring(0, { damping: 20, stiffness: 200, mass: 0.8 }); + } + }); - const onTouchEnd = () => { - if (disabled || done) return; - if (maxX > 0 && currentX.current / maxX >= THRESHOLD) { - Animated.timing(translateX, { toValue: maxX, duration: 100, useNativeDriver: false }).start(() => { - setDone(true); - onConfirm(); - }); - } else { - Animated.spring(translateX, { toValue: 0, useNativeDriver: false, speed: 25, bounciness: 4 }).start(); - currentX.current = 0; - } - }; + const animatedHandle = useAnimatedStyle(() => ({ + transform: [{ translateX: translateX.value }], + })); - // Fade label as handle moves right - const safeMax = Math.max(maxX * 0.5, 1); - const labelOpacity = translateX.interpolate({ - inputRange: [0, safeMax], - outputRange: [1, 0], - extrapolate: 'clamp', - }); + const animatedFill = useAnimatedStyle(() => ({ + width: translateX.value + HANDLE_SIZE + TRACK_PADDING, + })); + + const animatedLabel = useAnimatedStyle(() => ({ + opacity: Math.max(0, 1 - (translateX.value / Math.max(maxX.value * 0.5, 1))), + })); return ( - setTrackW(e.nativeEvent.layout.width)} - style={[styles.track, { - backgroundColor: done ? color : '#F1F5F9', - borderColor: done ? color : '#E2E8F0', - }]} - > - {/* Label */} - - {done ? '✓ Confirmed!' : label} - + // GestureHandlerRootView inside Modal is REQUIRED on iOS New Arch + // Modal creates a new UIWindow — RNGH's gesture manager must be re-rooted here + + { + const w = e.nativeEvent.layout.width; + maxX.value = w - HANDLE_SIZE - TRACK_PADDING * 2; + }} + > + {/* Colored fill that grows behind the handle */} + - {/* Handle — touch events here */} - {!done && ( - - - - )} - + {label} + + + {/* Draggable handle */} + + + + + + + ); } const styles = StyleSheet.create({ + root: { width: '100%' }, track: { - height: TRACK_HEIGHT, - borderRadius: TRACK_HEIGHT / 2, - borderWidth: 1.5, - justifyContent: 'center', - alignItems: 'center', - overflow: 'hidden', + height: TRACK_HEIGHT, borderRadius: TRACK_HEIGHT / 2, + backgroundColor: '#F1F5F9', borderWidth: 1.5, + justifyContent: 'center', alignItems: 'center', + overflow: 'hidden', position: 'relative', + }, + fill: { + position: 'absolute', left: 0, top: 0, + height: TRACK_HEIGHT, zIndex: 1, }, label: { - fontSize: 15, - fontWeight: '700', - letterSpacing: 0.3, - textAlign: 'center', - paddingLeft: HANDLE_SIZE + PADDING * 2, - paddingRight: 16, + position: 'absolute', zIndex: 2, + fontSize: 14, fontWeight: '700', color: '#64748B', + textAlign: 'center', width: '100%', paddingRight: 16, }, handle: { - position: 'absolute', - left: PADDING, - top: PADDING, - width: HANDLE_SIZE, - height: HANDLE_SIZE, - borderRadius: HANDLE_SIZE / 2, - alignItems: 'center', - justifyContent: 'center', - elevation: 4, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.2, - shadowRadius: 4, + position: 'absolute', zIndex: 3, + justifyContent: 'center', alignItems: 'center', + shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.18, shadowRadius: 4, elevation: 4, }, + arrow: { fontSize: 26, color: '#fff', fontWeight: '800', marginLeft: 2 }, });