Files
fiberops-mobile/components/SlideToConfirm.tsx

245 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 },
});