From a4b00bad97f973686e3654c40a762373b46aa1fe Mon Sep 17 00:00:00 2001 From: Nemo Date: Tue, 24 Mar 2026 13:12:53 +0800 Subject: [PATCH] fix: rewrite SlideToConfirm with onTouchStart/Move/End instead of PanResponder - works in Modals --- components/SlideToConfirm.tsx | 106 +++++++++++++++++++--------------- 1 file changed, 59 insertions(+), 47 deletions(-) diff --git a/components/SlideToConfirm.tsx b/components/SlideToConfirm.tsx index e2015a9..3dc233e 100644 --- a/components/SlideToConfirm.tsx +++ b/components/SlideToConfirm.tsx @@ -1,10 +1,10 @@ import { useRef, useState } from 'react'; -import { View, Text, PanResponder, Animated, StyleSheet } from 'react-native'; +import { View, Text, Animated, StyleSheet } from 'react-native'; -const TRACK_HEIGHT = 58; -const HANDLE_SIZE = 46; +const TRACK_HEIGHT = 60; +const HANDLE_SIZE = 48; const PADDING = 6; -const THRESHOLD = 0.85; // 85% of track = confirmed +const THRESHOLD = 0.82; interface Props { label?: string; @@ -14,42 +14,43 @@ interface Props { } export function SlideToConfirm({ label = 'Slide to confirm', color = '#059669', onConfirm, disabled = false }: Props) { - const pan = useRef(new Animated.Value(0)).current; - const [done, setDone] = useState(false); 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 maxX = trackW - HANDLE_SIZE - PADDING * 2; + const maxX = Math.max(trackW - HANDLE_SIZE - PADDING * 2, 1); - const panResponder = useRef( - PanResponder.create({ - onStartShouldSetPanResponder: () => !disabled && !done, - onStartShouldSetPanResponderCapture: () => !disabled && !done, - onMoveShouldSetPanResponder: () => !disabled && !done, - onMoveShouldSetPanResponderCapture: () => !disabled && !done, - onPanResponderMove: (_, gs) => { - const x = Math.max(0, Math.min(gs.dx, maxX)); - pan.setValue(x); - }, - onPanResponderRelease: (_, gs) => { - const x = Math.max(0, Math.min(gs.dx, maxX)); - if (maxX > 0 && x / maxX >= THRESHOLD) { - // Snap to end + confirm - Animated.timing(pan, { toValue: maxX, duration: 120, useNativeDriver: false }).start(() => { - setDone(true); - onConfirm(); - }); - } else { - // Snap back - Animated.spring(pan, { toValue: 0, useNativeDriver: false, speed: 20 }).start(); - } - }, - }) - ).current; + const onTouchStart = (e: any) => { + if (disabled || done) return; + startX.current = e.nativeEvent.pageX; + }; - // Interpolate opacity of the label as handle moves right - // Guard: inputRange must be monotonically non-decreasing (maxX could be 0 before layout) + 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 onTouchEnd = () => { + if (disabled || done) return; + if (maxX > 0 && currentX.current / maxX >= THRESHOLD) { + Animated.timing(translateX, { toValue: maxX, duration: 100, useNativeDriver: true }).start(() => { + setDone(true); + onConfirm(); + }); + } else { + Animated.spring(translateX, { toValue: 0, useNativeDriver: true, speed: 25, bounciness: 4 }).start(); + currentX.current = 0; + } + }; + + // Fade label as handle moves right const safeMax = Math.max(maxX * 0.5, 1); - const labelOpacity = pan.interpolate({ + const labelOpacity = translateX.interpolate({ inputRange: [0, safeMax], outputRange: [1, 0], extrapolate: 'clamp', @@ -58,23 +59,31 @@ export function SlideToConfirm({ label = 'Slide to confirm', color = '#059669', return ( setTrackW(e.nativeEvent.layout.width)} - style={[styles.track, { backgroundColor: done ? color : '#F1F5F9', borderColor: done ? color : '#E2E8F0' }]} + style={[styles.track, { + backgroundColor: done ? color : '#F1F5F9', + borderColor: done ? color : '#E2E8F0', + }]} > {/* Label */} - + {done ? '✓ Confirmed!' : label} - {/* Handle */} + {/* Handle — touch events here */} {!done && ( - {'›'} + )} @@ -89,25 +98,28 @@ const styles = StyleSheet.create({ justifyContent: 'center', alignItems: 'center', overflow: 'hidden', - position: 'relative', }, label: { fontSize: 15, fontWeight: '700', letterSpacing: 0.3, + textAlign: 'center', + paddingLeft: HANDLE_SIZE + PADDING * 2, + paddingRight: 16, }, handle: { position: 'absolute', + left: PADDING, top: PADDING, width: HANDLE_SIZE, height: HANDLE_SIZE, borderRadius: HANDLE_SIZE / 2, alignItems: 'center', justifyContent: 'center', - elevation: 3, + elevation: 4, shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.15, + shadowOpacity: 0.2, shadowRadius: 4, }, });