fix: SlideToConfirm pure PanResponder+Animated - no Reanimated/RNGH (Expo Go compat)

This commit is contained in:
Nemo
2026-03-24 14:18:33 +08:00
parent 6d3729406b
commit c1da787656

View File

@@ -1,16 +1,16 @@
/**
* SlideToConfirm — No RNGH dependency.
* Uses Reanimated useSharedValue (JSI-direct, sets on UI thread immediately)
* + onTouchStart/Move/End on the handle for gesture capture.
* SlideToConfirm — pure RN, no RNGH, no Reanimated.
* PanResponder on handle + Animated(useNativeDriver:false).
*/
import { useRef } from 'react';
import { StyleSheet, Text, View, ViewStyle } from 'react-native';
import Animated, {
runOnJS,
useAnimatedStyle,
useSharedValue,
withSpring,
} from 'react-native-reanimated';
import {
Animated,
PanResponder,
StyleSheet,
Text,
View,
ViewStyle,
} from 'react-native';
interface Props {
onConfirm: () => void;
@@ -20,66 +20,82 @@ interface Props {
}
const HANDLE_SIZE = 56;
const THRESHOLD = 0.80; // 80% of track
const THRESHOLD = 0.80;
export function SlideToConfirm({ onConfirm, label = 'Slide to confirm', disabled = false, style }: Props) {
const trackWidth = useRef(0);
const startX = useRef(0);
const confirmed = useRef(false);
const translateX = useSharedValue(0);
const translateX = useRef(new Animated.Value(0)).current;
const handleStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }],
}));
const labelStyle = useAnimatedStyle(() => {
const maxX = Math.max(trackWidth.current - HANDLE_SIZE, 1);
const ratio = translateX.value / maxX;
return { opacity: 1 - ratio * 1.5 };
});
const onTouchStart = (e: any) => {
if (disabled) return;
const panResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => !disabled,
onMoveShouldSetPanResponder: () => !disabled,
onPanResponderGrant: () => {
confirmed.current = false;
startX.current = e.nativeEvent.pageX - translateX.value;
};
const onTouchMove = (e: any) => {
if (disabled) return;
const maxX = Math.max(trackWidth.current - HANDLE_SIZE, 1);
const raw = e.nativeEvent.pageX - startX.current;
const clamped = Math.min(Math.max(raw, 0), maxX);
translateX.value = clamped;
(translateX as any).setOffset((translateX as any)._value);
translateX.setValue(0);
},
onPanResponderMove: (_, gs) => {
const maxX = Math.max(trackWidth.current - HANDLE_SIZE - 6, 1);
const clamped = Math.min(Math.max(gs.dx, 0), maxX);
translateX.setValue(clamped - (translateX as any)._offset);
if (!confirmed.current && clamped / maxX >= THRESHOLD) {
confirmed.current = true;
translateX.value = withSpring(maxX, { damping: 18, stiffness: 220 });
runOnJS(onConfirm)();
(translateX as any).flattenOffset();
Animated.spring(translateX, {
toValue: maxX,
useNativeDriver: false,
damping: 18,
stiffness: 220,
}).start();
onConfirm();
}
};
},
onPanResponderRelease: () => {
(translateX as any).flattenOffset();
if (!confirmed.current) {
Animated.spring(translateX, {
toValue: 0,
useNativeDriver: false,
damping: 18,
stiffness: 220,
}).start();
}
},
onPanResponderTerminate: () => {
(translateX as any).flattenOffset();
if (!confirmed.current) {
Animated.spring(translateX, {
toValue: 0,
useNativeDriver: false,
}).start();
}
},
})
).current;
const onTouchEnd = () => {
if (disabled || confirmed.current) return;
translateX.value = withSpring(0, { damping: 18, stiffness: 220 });
};
const maxXForOpacity = Math.max(trackWidth.current - HANDLE_SIZE - 6, 1);
const labelOpacity = translateX.interpolate({
inputRange: [0, maxXForOpacity > 0 ? maxXForOpacity * 0.6 : 1],
outputRange: [1, 0],
extrapolate: 'clamp',
});
return (
<View
style={[styles.track, disabled && styles.trackDisabled, style]}
onLayout={e => { trackWidth.current = e.nativeEvent.layout.width; }}
>
{/* Label fades as handle moves right */}
<Animated.View style={[StyleSheet.absoluteFill, styles.labelWrap, labelStyle]} pointerEvents="none">
<Animated.View style={[StyleSheet.absoluteFill, styles.labelWrap, { opacity: labelOpacity }]} pointerEvents="none">
<Text style={styles.label}>{label}</Text>
</Animated.View>
{/* Sliding handle */}
<Animated.View
style={[styles.handle, disabled && styles.handleDisabled, handleStyle]}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
onTouchCancel={onTouchEnd}
style={[styles.handle, disabled && styles.handleDisabled, { transform: [{ translateX }] }]}
{...panResponder.panHandlers}
collapsable={false}
>
<Text style={styles.arrow}></Text>
</Animated.View>
@@ -95,13 +111,8 @@ const styles = StyleSheet.create({
justifyContent: 'center',
overflow: 'hidden',
},
trackDisabled: {
backgroundColor: '#94A3B8',
},
labelWrap: {
alignItems: 'center',
justifyContent: 'center',
},
trackDisabled: { backgroundColor: '#94A3B8' },
labelWrap: { alignItems: 'center', justifyContent: 'center' },
label: {
color: 'rgba(255,255,255,0.85)',
fontSize: 15,
@@ -122,9 +133,7 @@ const styles = StyleSheet.create({
shadowRadius: 4,
elevation: 4,
},
handleDisabled: {
backgroundColor: '#E2E8F0',
},
handleDisabled: { backgroundColor: '#E2E8F0' },
arrow: {
fontSize: 28,
color: '#0891B2',