245 lines
6.7 KiB
TypeScript
245 lines
6.7 KiB
TypeScript
/**
|
||
* SlideToConfirm — pure RN, no RNGH, no Reanimated.
|
||
*
|
||
* Key findings (Conan investigation):
|
||
* - Animated.event + useNativeDriver:true is BROKEN with PanResponder — gestureState
|
||
* (dx/dy) is JS-computed so native driver skips setValue() entirely. Never use.
|
||
* - useNativeDriver: false is fine on New Arch (Fabric+JSI) — no bridge penalty.
|
||
* - onStartShouldSetPanResponderCapture claims gesture in capture phase BEFORE
|
||
* the Modal container can contest it on iOS — fixes the delayed-grant lag.
|
||
*/
|
||
import { useEffect, useRef, useState } from 'react';
|
||
import {
|
||
Animated,
|
||
PanResponder,
|
||
StyleSheet,
|
||
Text,
|
||
View,
|
||
ViewStyle,
|
||
} from 'react-native';
|
||
|
||
interface Props {
|
||
onConfirm: () => void;
|
||
label?: string;
|
||
disabled?: boolean;
|
||
style?: ViewStyle;
|
||
color?: string;
|
||
}
|
||
|
||
const HANDLE_SIZE = 56;
|
||
const HANDLE_PADDING = 3;
|
||
const THRESHOLD = 0.80;
|
||
const TIP_DISTANCE = 28;
|
||
const TIP_DURATION = 260;
|
||
const SNAP_DURATION = 260;
|
||
|
||
export function SlideToConfirm({
|
||
onConfirm,
|
||
label = 'Slide to confirm',
|
||
disabled = false,
|
||
style,
|
||
color,
|
||
}: Props) {
|
||
const trackWidthRef = useRef(0);
|
||
const [trackWidthState, setTrackWidthState] = useState(0);
|
||
|
||
// useNativeDriver: false — mandatory for PanResponder gestureState (dx/dy are JS-computed)
|
||
const pan = useRef(new Animated.Value(0)).current;
|
||
|
||
const [isConfirmed, setIsConfirmed] = useState(false);
|
||
const confirmedRef = useRef(false);
|
||
|
||
// Live refs — break stale closures inside PanResponder (created once in useRef)
|
||
const disabledRef = useRef(disabled);
|
||
disabledRef.current = disabled;
|
||
const onConfirmRef = useRef(onConfirm);
|
||
onConfirmRef.current = onConfirm;
|
||
|
||
const getMaxX = () =>
|
||
Math.max(trackWidthRef.current - HANDLE_SIZE - HANDLE_PADDING * 2, 1);
|
||
|
||
const snapToEnd = () => {
|
||
Animated.spring(pan, {
|
||
toValue: getMaxX(),
|
||
useNativeDriver: false,
|
||
damping: 18,
|
||
stiffness: 220,
|
||
}).start(({ finished }) => {
|
||
if (finished) {
|
||
setIsConfirmed(true);
|
||
onConfirmRef.current();
|
||
}
|
||
});
|
||
};
|
||
|
||
const snapBack = () => {
|
||
Animated.timing(pan, {
|
||
toValue: 0,
|
||
duration: SNAP_DURATION,
|
||
useNativeDriver: false,
|
||
}).start(({ finished }) => {
|
||
if (finished && !disabledRef.current) runTip();
|
||
});
|
||
};
|
||
|
||
const runTip = () => {
|
||
if (disabledRef.current) return;
|
||
Animated.sequence([
|
||
Animated.timing(pan, { toValue: TIP_DISTANCE, duration: TIP_DURATION, useNativeDriver: false }),
|
||
Animated.timing(pan, { toValue: 0, duration: TIP_DURATION, useNativeDriver: false }),
|
||
]).start();
|
||
};
|
||
|
||
useEffect(() => {
|
||
const t = setTimeout(runTip, 700);
|
||
return () => clearTimeout(t);
|
||
}, []);
|
||
|
||
const panResponder = useRef(
|
||
PanResponder.create({
|
||
// Capture phase — claims gesture BEFORE Modal container can contest it on iOS
|
||
// This prevents the delayed-grant that made v1/v2 appear laggy
|
||
onStartShouldSetPanResponderCapture: () =>
|
||
!disabledRef.current && trackWidthRef.current > 0,
|
||
onStartShouldSetPanResponder: () =>
|
||
!disabledRef.current && trackWidthRef.current > 0,
|
||
onMoveShouldSetPanResponder: (_, gs) =>
|
||
!disabledRef.current &&
|
||
trackWidthRef.current > 0 &&
|
||
Math.abs(gs.dx) > Math.abs(gs.dy),
|
||
|
||
onPanResponderGrant: () => {
|
||
confirmedRef.current = false;
|
||
pan.stopAnimation();
|
||
pan.setValue(0);
|
||
},
|
||
|
||
onPanResponderMove: (_, gs) => {
|
||
const maxX = getMaxX();
|
||
const clamped = Math.min(Math.max(gs.dx, 0), maxX);
|
||
pan.setValue(clamped);
|
||
},
|
||
|
||
onPanResponderRelease: (_, gs) => {
|
||
const maxX = getMaxX();
|
||
const clamped = Math.min(Math.max(gs.dx, 0), maxX);
|
||
if (!confirmedRef.current && clamped / maxX >= THRESHOLD) {
|
||
confirmedRef.current = true;
|
||
snapToEnd();
|
||
} else {
|
||
snapBack();
|
||
}
|
||
},
|
||
|
||
onPanResponderTerminate: () => {
|
||
if (!confirmedRef.current) snapBack();
|
||
},
|
||
})
|
||
).current;
|
||
|
||
const maxX = Math.max(trackWidthState - HANDLE_SIZE - HANDLE_PADDING * 2, 1);
|
||
const labelOpacity = pan.interpolate({
|
||
inputRange: [0, maxX * 0.5],
|
||
outputRange: [1, 0],
|
||
extrapolate: 'clamp',
|
||
});
|
||
const checkOpacity = pan.interpolate({
|
||
inputRange: [maxX * 0.6, maxX],
|
||
outputRange: [0, 1],
|
||
extrapolate: 'clamp',
|
||
});
|
||
const clampedX = pan.interpolate({
|
||
inputRange: [0, maxX],
|
||
outputRange: [0, maxX],
|
||
extrapolate: 'clamp',
|
||
});
|
||
|
||
const trackColor = color && !disabled ? color : undefined;
|
||
|
||
return (
|
||
<View
|
||
style={[
|
||
styles.track,
|
||
disabled && styles.trackDisabled,
|
||
trackColor ? { backgroundColor: trackColor } : undefined,
|
||
style,
|
||
]}
|
||
onLayout={e => {
|
||
const w = e.nativeEvent.layout.width;
|
||
trackWidthRef.current = w;
|
||
setTrackWidthState(w);
|
||
}}
|
||
>
|
||
<Animated.View
|
||
style={[StyleSheet.absoluteFill, styles.center, { opacity: labelOpacity }]}
|
||
pointerEvents="none"
|
||
>
|
||
<Text style={styles.label}>{label}</Text>
|
||
</Animated.View>
|
||
|
||
<Animated.View
|
||
style={[StyleSheet.absoluteFill, styles.center, { opacity: checkOpacity }]}
|
||
pointerEvents="none"
|
||
>
|
||
<Text style={[styles.label, styles.confirmedLabel]}>Confirmed ✓</Text>
|
||
</Animated.View>
|
||
|
||
<Animated.View
|
||
style={[
|
||
styles.handle,
|
||
disabled && styles.handleDisabled,
|
||
{ transform: [{ translateX: clampedX }] },
|
||
]}
|
||
{...panResponder.panHandlers}
|
||
collapsable={false}
|
||
>
|
||
{isConfirmed
|
||
? <Text style={[styles.icon, styles.checkIcon]}>✓</Text>
|
||
: <Text style={styles.icon}>›</Text>
|
||
}
|
||
</Animated.View>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
track: {
|
||
height: 60,
|
||
backgroundColor: '#0E7490',
|
||
borderRadius: 30,
|
||
justifyContent: 'center',
|
||
overflow: 'hidden',
|
||
},
|
||
trackDisabled: { backgroundColor: '#94A3B8' },
|
||
center: { alignItems: 'center', justifyContent: 'center' },
|
||
label: {
|
||
color: 'rgba(255,255,255,0.85)',
|
||
fontSize: 15,
|
||
fontWeight: '600',
|
||
letterSpacing: 0.3,
|
||
},
|
||
confirmedLabel: { color: '#fff', fontWeight: '700' },
|
||
handle: {
|
||
width: HANDLE_SIZE,
|
||
height: HANDLE_SIZE,
|
||
borderRadius: HANDLE_SIZE / 2,
|
||
backgroundColor: '#fff',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
marginLeft: HANDLE_PADDING,
|
||
shadowColor: '#000',
|
||
shadowOffset: { width: 0, height: 2 },
|
||
shadowOpacity: 0.18,
|
||
shadowRadius: 4,
|
||
elevation: 4,
|
||
},
|
||
handleDisabled: { backgroundColor: '#E2E8F0' },
|
||
icon: {
|
||
fontSize: 28,
|
||
color: '#0891B2',
|
||
fontWeight: '800',
|
||
lineHeight: 32,
|
||
},
|
||
checkIcon: { color: '#059669', fontSize: 24 },
|
||
});
|