fix: rewrite SlideToConfirm with onTouchStart/Move/End instead of PanResponder - works in Modals

This commit is contained in:
Nemo
2026-03-24 13:12:53 +08:00
parent 024bb06205
commit a4b00bad97

View File

@@ -1,10 +1,10 @@
import { useRef, useState } from 'react'; 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 TRACK_HEIGHT = 60;
const HANDLE_SIZE = 46; const HANDLE_SIZE = 48;
const PADDING = 6; const PADDING = 6;
const THRESHOLD = 0.85; // 85% of track = confirmed const THRESHOLD = 0.82;
interface Props { interface Props {
label?: string; label?: string;
@@ -14,42 +14,43 @@ interface Props {
} }
export function SlideToConfirm({ label = 'Slide to confirm', color = '#059669', onConfirm, disabled = false }: 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 [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( const onTouchStart = (e: any) => {
PanResponder.create({ if (disabled || done) return;
onStartShouldSetPanResponder: () => !disabled && !done, startX.current = e.nativeEvent.pageX;
onStartShouldSetPanResponderCapture: () => !disabled && !done, };
onMoveShouldSetPanResponder: () => !disabled && !done,
onMoveShouldSetPanResponderCapture: () => !disabled && !done, const onTouchMove = (e: any) => {
onPanResponderMove: (_, gs) => { if (disabled || done) return;
const x = Math.max(0, Math.min(gs.dx, maxX)); const dx = e.nativeEvent.pageX - startX.current;
pan.setValue(x); const x = Math.max(0, Math.min(dx, maxX));
}, currentX.current = x;
onPanResponderRelease: (_, gs) => { translateX.setValue(x);
const x = Math.max(0, Math.min(gs.dx, maxX)); };
if (maxX > 0 && x / maxX >= THRESHOLD) {
// Snap to end + confirm const onTouchEnd = () => {
Animated.timing(pan, { toValue: maxX, duration: 120, useNativeDriver: false }).start(() => { if (disabled || done) return;
if (maxX > 0 && currentX.current / maxX >= THRESHOLD) {
Animated.timing(translateX, { toValue: maxX, duration: 100, useNativeDriver: true }).start(() => {
setDone(true); setDone(true);
onConfirm(); onConfirm();
}); });
} else { } else {
// Snap back Animated.spring(translateX, { toValue: 0, useNativeDriver: true, speed: 25, bounciness: 4 }).start();
Animated.spring(pan, { toValue: 0, useNativeDriver: false, speed: 20 }).start(); currentX.current = 0;
} }
}, };
})
).current;
// Interpolate opacity of the label as handle moves right // Fade label as handle moves right
// Guard: inputRange must be monotonically non-decreasing (maxX could be 0 before layout)
const safeMax = Math.max(maxX * 0.5, 1); const safeMax = Math.max(maxX * 0.5, 1);
const labelOpacity = pan.interpolate({ const labelOpacity = translateX.interpolate({
inputRange: [0, safeMax], inputRange: [0, safeMax],
outputRange: [1, 0], outputRange: [1, 0],
extrapolate: 'clamp', extrapolate: 'clamp',
@@ -58,23 +59,31 @@ export function SlideToConfirm({ label = 'Slide to confirm', color = '#059669',
return ( return (
<View <View
onLayout={e => setTrackW(e.nativeEvent.layout.width)} onLayout={e => 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 */} {/* Label */}
<Animated.Text style={[styles.label, { opacity: disabled ? 0.4 : labelOpacity, color: done ? '#fff' : '#64748B' }]}> <Animated.Text style={[styles.label, {
opacity: disabled ? 0.35 : labelOpacity,
color: done ? '#fff' : '#64748B',
}]}>
{done ? '✓ Confirmed!' : label} {done ? '✓ Confirmed!' : label}
</Animated.Text> </Animated.Text>
{/* Handle */} {/* Handle — touch events here */}
{!done && ( {!done && (
<Animated.View <Animated.View
{...(disabled ? {} : panResponder.panHandlers)} onTouchStart={onTouchStart}
style={[ onTouchMove={onTouchMove}
styles.handle, onTouchEnd={onTouchEnd}
{ backgroundColor: disabled ? '#CBD5E1' : color, left: PADDING, transform: [{ translateX: pan }] }, style={[styles.handle, {
]} backgroundColor: disabled ? '#CBD5E1' : color,
transform: [{ translateX }],
}]}
> >
<Text style={{ color: '#fff', fontSize: 20, fontWeight: '700' }}>{''}</Text> <Text style={{ color: '#fff', fontSize: 22, fontWeight: '700', marginLeft: 2 }}></Text>
</Animated.View> </Animated.View>
)} )}
</View> </View>
@@ -89,25 +98,28 @@ const styles = StyleSheet.create({
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
overflow: 'hidden', overflow: 'hidden',
position: 'relative',
}, },
label: { label: {
fontSize: 15, fontSize: 15,
fontWeight: '700', fontWeight: '700',
letterSpacing: 0.3, letterSpacing: 0.3,
textAlign: 'center',
paddingLeft: HANDLE_SIZE + PADDING * 2,
paddingRight: 16,
}, },
handle: { handle: {
position: 'absolute', position: 'absolute',
left: PADDING,
top: PADDING, top: PADDING,
width: HANDLE_SIZE, width: HANDLE_SIZE,
height: HANDLE_SIZE, height: HANDLE_SIZE,
borderRadius: HANDLE_SIZE / 2, borderRadius: HANDLE_SIZE / 2,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
elevation: 3, elevation: 4,
shadowColor: '#000', shadowColor: '#000',
shadowOffset: { width: 0, height: 2 }, shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.15, shadowOpacity: 0.2,
shadowRadius: 4, shadowRadius: 4,
}, },
}); });