Files
fiberops-mobile/components/SlideToConfirm.tsx

252 lines
6.8 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 fix: Animated.event pipes gesture data directly to native thread
* (no JS bridge delay) — this is what makes the handle actually track the finger.
* useNativeDriver: true on all animations for 60fps smoothness.
*/
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) {
// trackWidthRef — read inside PanResponder callbacks (no stale closure)
// trackWidthState — triggers re-render so opacity interpolation updates
const trackWidthRef = useRef(0);
const [trackWidthState, setTrackWidthState] = useState(0);
// pan drives all animation — useNativeDriver: true for all animations
const pan = useRef(new Animated.Value(0)).current;
const [isConfirmed, setIsConfirmed] = useState(false);
const confirmedRef = useRef(false);
// Live refs — break stale closures inside PanResponder
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: true,
damping: 18,
stiffness: 220,
}).start(({ finished }) => {
if (finished) {
setIsConfirmed(true);
onConfirmRef.current();
}
});
};
const snapBack = () => {
Animated.timing(pan, {
toValue: 0,
duration: SNAP_DURATION,
useNativeDriver: true,
}).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: true }),
Animated.timing(pan, { toValue: 0, duration: TIP_DURATION, useNativeDriver: true }),
]).start();
};
useEffect(() => {
const t = setTimeout(runTip, 700);
return () => clearTimeout(t);
}, []);
const panResponder = useRef(
PanResponder.create({
// Claim gesture on touch start — ensures dx=0 at grant, no jump
onStartShouldSetPanResponder: () =>
!disabledRef.current && trackWidthRef.current > 0,
// Also claim on move (horizontal dominant) as fallback
onMoveShouldSetPanResponder: (_, gs) =>
!disabledRef.current &&
trackWidthRef.current > 0 &&
Math.abs(gs.dx) > Math.abs(gs.dy),
onPanResponderGrant: () => {
confirmedRef.current = false;
pan.stopAnimation();
pan.setValue(0); // reset — Animated.event maps dx from 0
},
// Animated.event pipes dx directly to native thread — true 1:1 finger tracking
onPanResponderMove: Animated.event(
[null, { dx: pan }],
{ useNativeDriver: true }
),
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',
});
// Clamp translateX visually so handle doesn't overshoot track
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);
}}
>
{/* "Slide to confirm" label — fades out as handle moves right */}
<Animated.View
style={[StyleSheet.absoluteFill, styles.center, { opacity: labelOpacity }]}
pointerEvents="none"
>
<Text style={styles.label}>{label}</Text>
</Animated.View>
{/* "Confirmed ✓" label — fades in near end */}
<Animated.View
style={[StyleSheet.absoluteFill, styles.center, { opacity: checkOpacity }]}
pointerEvents="none"
>
<Text style={[styles.label, styles.confirmedLabel]}>Confirmed </Text>
</Animated.View>
{/* Sliding handle — driven by Animated.event (native thread) */}
<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,
},
});