Files
fiberops-mobile/components/SlideToConfirm.tsx

237 lines
6.6 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.
* Approach ported from rn-slide-to-confirm (PanResponder + pageX tracking).
*
* Features:
* - Tip bounce animation on mount (hints it's slideable)
* - Smooth finger tracking via pageX - startPoint
* - Threshold check on release (not mid-drag)
* - Chevron → Checkmark icon on confirm
* - disabledRef + onConfirmRef break stale closures
* - trackWidthRef + trackWidthState: layout guard before gesture
*/
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 = 30;
const TIP_DURATION = 280;
const SNAP_DURATION = 280;
export function SlideToConfirm({
onConfirm,
label = 'Slide to confirm',
disabled = false,
style,
color,
}: Props) {
// Track width — ref for PanResponder (no stale closure), state for re-render
const trackWidthRef = useRef(0);
const [trackWidthState, setTrackWidthState] = useState(0);
const pan = useRef(new Animated.Value(0)).current;
const startPageX = useRef(0);
const confirmed = useRef(false);
const [isConfirmed, setIsConfirmed] = useState(false);
// Live refs — break stale closures
const disabledRef = useRef(disabled);
disabledRef.current = disabled;
const onConfirmRef = useRef(onConfirm);
onConfirmRef.current = onConfirm;
// Tip bounce on mount
const runTip = () => {
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(() => {
// Wait for layout then bounce hint
const t = setTimeout(runTip, 600);
return () => clearTimeout(t);
}, []);
const snapToEnd = () => {
const maxX = trackWidthRef.current - HANDLE_SIZE - HANDLE_PADDING * 2;
Animated.spring(pan, {
toValue: maxX,
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 panResponder = useRef(
PanResponder.create({
// Block until layout measured
onStartShouldSetPanResponder: () =>
!disabledRef.current && trackWidthRef.current > 0,
onMoveShouldSetPanResponder: (_, gs) =>
!disabledRef.current &&
trackWidthRef.current > 0 &&
Math.abs(gs.dx) > Math.abs(gs.dy) * 1.5,
onPanResponderGrant: (e) => {
confirmed.current = false;
pan.stopAnimation();
startPageX.current = e.nativeEvent.pageX;
},
onPanResponderMove: (e) => {
const maxX = trackWidthRef.current - HANDLE_SIZE - HANDLE_PADDING * 2;
if (maxX <= 0) return;
const raw = e.nativeEvent.pageX - startPageX.current;
const clamped = Math.min(Math.max(raw, 0), maxX);
pan.setValue(clamped);
},
onPanResponderRelease: (e) => {
const maxX = trackWidthRef.current - HANDLE_SIZE - HANDLE_PADDING * 2;
if (maxX <= 0) return;
const raw = e.nativeEvent.pageX - startPageX.current;
const clamped = Math.min(Math.max(raw, 0), maxX);
if (clamped / maxX >= THRESHOLD) {
confirmed.current = true;
snapToEnd();
} else {
snapBack();
}
},
onPanResponderTerminate: () => {
if (!confirmed.current) snapBack();
},
})
).current;
const maxXForFade = Math.max(trackWidthState - HANDLE_SIZE - HANDLE_PADDING * 2, 1);
const labelOpacity = pan.interpolate({
inputRange: [0, maxXForFade * 0.6],
outputRange: [1, 0],
extrapolate: 'clamp',
});
const checkOpacity = pan.interpolate({
inputRange: [maxXForFade * 0.6, maxXForFade],
outputRange: [0, 1],
extrapolate: 'clamp',
});
const trackBg = color && !disabled ? color : undefined;
return (
<View
style={[styles.track, disabled && styles.trackDisabled, trackBg ? { backgroundColor: trackBg } : undefined, style]}
onLayout={e => {
const w = e.nativeEvent.layout.width;
trackWidthRef.current = w;
setTrackWidthState(w);
}}
>
{/* Label — fades out as handle moves right */}
<Animated.View style={[StyleSheet.absoluteFill, styles.labelWrap, { opacity: labelOpacity }]} pointerEvents="none">
<Text style={styles.label}>{label}</Text>
</Animated.View>
{/* "Confirmed" label — fades in near end */}
<Animated.View style={[StyleSheet.absoluteFill, styles.labelWrap, { opacity: checkOpacity }]} pointerEvents="none">
<Text style={[styles.label, styles.confirmedLabel]}>Confirmed </Text>
</Animated.View>
{/* Sliding handle */}
<Animated.View
style={[styles.handle, disabled && styles.handleDisabled, { transform: [{ translateX: pan }] }]}
{...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' },
labelWrap: { 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,
},
});