Files
fiberops-mobile/components/SlideToConfirm.tsx

156 lines
4.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.
* PanResponder on handle + Animated(useNativeDriver:false).
*
* Fix: live refs break stale closures — disabledRef + onConfirmRef
* updated on every render so PanResponder always reads current values.
*/
import { useRef } 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 THRESHOLD = 0.80;
export function SlideToConfirm({ onConfirm, label = 'Slide to confirm', disabled = false, style, color }: Props) {
const trackWidth = useRef(0);
const confirmed = useRef(false);
const translateX = useRef(new Animated.Value(0)).current;
// ── Live refs: break stale closures inside PanResponder ──────────────────
const disabledRef = useRef(disabled);
disabledRef.current = disabled; // updated on every render
const onConfirmRef = useRef(onConfirm);
onConfirmRef.current = onConfirm; // updated on every render
const panResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => !disabledRef.current,
// Horizontal-dominance check prevents ScrollView stealing vertical-ish drags on iOS
onMoveShouldSetPanResponder: (_, gs) =>
!disabledRef.current && Math.abs(gs.dx) > Math.abs(gs.dy) * 2,
onPanResponderGrant: () => {
confirmed.current = false;
(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 as any).flattenOffset();
Animated.spring(translateX, {
toValue: maxX,
useNativeDriver: false,
damping: 18,
stiffness: 220,
}).start();
onConfirmRef.current(); // always calls latest 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 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, color && !disabled ? { backgroundColor: color } : undefined, style]}
onLayout={e => { trackWidth.current = e.nativeEvent.layout.width; }}
>
<Animated.View style={[StyleSheet.absoluteFill, styles.labelWrap, { opacity: labelOpacity }]} pointerEvents="none">
<Text style={styles.label}>{label}</Text>
</Animated.View>
<Animated.View
style={[styles.handle, disabled && styles.handleDisabled, { transform: [{ translateX }] }]}
{...panResponder.panHandlers}
collapsable={false}
>
<Text style={styles.arrow}></Text>
</Animated.View>
</View>
);
}
const styles = StyleSheet.create({
track: {
height: 60,
backgroundColor: '#0E7490',
borderRadius: 30,
justifyContent: 'center',
overflow: 'hidden',
},
trackDisabled: { backgroundColor: '#94A3B8' },
labelWrap: { alignItems: 'center', justifyContent: 'center' },
label: {
color: 'rgba(255,255,255,0.85)',
fontSize: 15,
fontWeight: '600',
letterSpacing: 0.3,
},
handle: {
width: HANDLE_SIZE,
height: HANDLE_SIZE,
borderRadius: HANDLE_SIZE / 2,
backgroundColor: '#fff',
alignItems: 'center',
justifyContent: 'center',
marginLeft: 3,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.18,
shadowRadius: 4,
elevation: 4,
},
handleDisabled: { backgroundColor: '#E2E8F0' },
arrow: {
fontSize: 28,
color: '#0891B2',
fontWeight: '800',
lineHeight: 32,
},
});