Files
fiberops-mobile/components/SlideToConfirm.tsx

201 lines
5.9 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 history:
* - v1: stale closure fix — disabledRef + onConfirmRef
* - v2: trackWidth=0 snap bug — trackWidthRef+State, startPos/currentPos refs,
* eliminated _value/_offset private API, layout guard on gesture start
*/
import { 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_MARGIN = 3;
const THRESHOLD = 0.80;
export function SlideToConfirm({ onConfirm, label = 'Slide to confirm', disabled = false, style, color }: Props) {
// trackWidthRef — read inside PanResponder (no stale closure)
// trackWidthState — triggers re-render so labelOpacity interpolation updates
const trackWidthRef = useRef(0);
const [trackWidthState, setTrackWidthState] = useState(0);
const confirmed = useRef(false);
const translateX = useRef(new Animated.Value(0)).current;
// Explicit position tracking — avoids private _value/_offset API
const startPos = useRef(0); // handle position when gesture starts
const currentPos = useRef(0); // handle position during/after gesture
// ── Live refs: break stale closures ──────────────────────────────────────
const disabledRef = useRef(disabled);
disabledRef.current = disabled;
const onConfirmRef = useRef(onConfirm);
onConfirmRef.current = onConfirm;
const panResponder = useRef(
PanResponder.create({
// Block gestures until layout has been measured
onStartShouldSetPanResponder: () =>
!disabledRef.current && trackWidthRef.current > 0,
// Horizontal-dominance — prevents ScrollView stealing on iOS
onMoveShouldSetPanResponder: (_, gs) =>
!disabledRef.current &&
trackWidthRef.current > 0 &&
Math.abs(gs.dx) > Math.abs(gs.dy) * 2,
onPanResponderGrant: () => {
confirmed.current = false;
// Stop any running spring before starting a new drag
translateX.stopAnimation();
startPos.current = currentPos.current;
},
onPanResponderMove: (_, gs) => {
const maxX = trackWidthRef.current - HANDLE_SIZE - HANDLE_MARGIN * 2;
if (maxX <= 0) return; // safety — layout not ready
const raw = startPos.current + gs.dx;
const clamped = Math.min(Math.max(raw, 0), maxX);
currentPos.current = clamped;
translateX.setValue(clamped);
if (!confirmed.current && clamped / maxX >= THRESHOLD) {
confirmed.current = true;
Animated.spring(translateX, {
toValue: maxX,
useNativeDriver: false,
damping: 18,
stiffness: 220,
}).start(({ finished }) => {
if (finished) currentPos.current = maxX;
});
onConfirmRef.current();
}
},
onPanResponderRelease: () => {
if (!confirmed.current) {
currentPos.current = 0;
Animated.spring(translateX, {
toValue: 0,
useNativeDriver: false,
damping: 18,
stiffness: 220,
}).start(({ finished }) => {
if (finished) currentPos.current = 0;
});
}
},
onPanResponderTerminate: () => {
if (!confirmed.current) {
currentPos.current = 0;
Animated.spring(translateX, {
toValue: 0,
useNativeDriver: false,
}).start(({ finished }) => {
if (finished) currentPos.current = 0;
});
}
},
})
).current;
const maxXForOpacity = Math.max(trackWidthState - HANDLE_SIZE - HANDLE_MARGIN * 2, 1);
const labelOpacity = translateX.interpolate({
inputRange: [0, maxXForOpacity * 0.6],
outputRange: [1, 0],
extrapolate: 'clamp',
});
return (
<View
style={[
styles.track,
disabled && styles.trackDisabled,
color && !disabled ? { backgroundColor: color } : undefined,
style,
]}
onLayout={e => {
const w = e.nativeEvent.layout.width;
trackWidthRef.current = w; // PanResponder reads this
setTrackWidthState(w); // triggers re-render for labelOpacity
}}
>
<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: HANDLE_MARGIN,
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,
},
});