fix: SlideToConfirm - RNGH GestureHandlerRootView + Reanimated worklets (proper Modal iOS fix)

This commit is contained in:
Nemo
2026-03-24 13:24:35 +08:00
parent c0802e8635
commit e419f64bc2

View File

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