feat: SlideToConfirm v3 — pageX tracking, tip bounce, check icon, threshold on release

This commit is contained in:
Nemo
2026-03-25 06:12:51 +08:00
parent b431d83d13
commit bf1e36f9e7

View File

@@ -1,13 +1,16 @@
/** /**
* SlideToConfirm — pure RN, no RNGH, no Reanimated. * SlideToConfirm — pure RN, no RNGH, no Reanimated.
* PanResponder on handle + Animated(useNativeDriver:false). * Approach ported from rn-slide-to-confirm (PanResponder + pageX tracking).
* *
* Fix history: * Features:
* - v1: stale closure fix — disabledRef + onConfirmRef * - Tip bounce animation on mount (hints it's slideable)
* - v2: trackWidth=0 snap bug — trackWidthRef+State, startPos/currentPos refs, * - Smooth finger tracking via pageX - startPoint
* eliminated _value/_offset private API, layout guard on gesture start * - Threshold check on release (not mid-drag)
* - Chevron → Checkmark icon on confirm
* - disabledRef + onConfirmRef break stale closures
* - trackWidthRef + trackWidthState: layout guard before gesture
*/ */
import { useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { import {
Animated, Animated,
PanResponder, PanResponder,
@@ -25,136 +28,161 @@ interface Props {
color?: string; color?: string;
} }
const HANDLE_SIZE = 56; const HANDLE_SIZE = 56;
const HANDLE_MARGIN = 3; const HANDLE_PADDING = 3;
const THRESHOLD = 0.80; 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) { export function SlideToConfirm({
// trackWidthRef — read inside PanResponder (no stale closure) onConfirm,
// trackWidthState — triggers re-render so labelOpacity interpolation updates label = 'Slide to confirm',
const trackWidthRef = useRef(0); 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 [trackWidthState, setTrackWidthState] = useState(0);
const confirmed = useRef(false); const pan = useRef(new Animated.Value(0)).current;
const translateX = useRef(new Animated.Value(0)).current; const startPageX = useRef(0);
const confirmed = useRef(false);
const [isConfirmed, setIsConfirmed] = useState(false);
// Explicit position tracking — avoids private _value/_offset API // Live refs — break stale closures
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); const disabledRef = useRef(disabled);
disabledRef.current = disabled; disabledRef.current = disabled;
const onConfirmRef = useRef(onConfirm); const onConfirmRef = useRef(onConfirm);
onConfirmRef.current = 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( const panResponder = useRef(
PanResponder.create({ PanResponder.create({
// Block gestures until layout has been measured // Block until layout measured
onStartShouldSetPanResponder: () => onStartShouldSetPanResponder: () =>
!disabledRef.current && trackWidthRef.current > 0, !disabledRef.current && trackWidthRef.current > 0,
// Horizontal-dominance — prevents ScrollView stealing on iOS
onMoveShouldSetPanResponder: (_, gs) => onMoveShouldSetPanResponder: (_, gs) =>
!disabledRef.current && !disabledRef.current &&
trackWidthRef.current > 0 && trackWidthRef.current > 0 &&
Math.abs(gs.dx) > Math.abs(gs.dy) * 2, Math.abs(gs.dx) > Math.abs(gs.dy) * 1.5,
onPanResponderGrant: () => { onPanResponderGrant: (e) => {
confirmed.current = false; confirmed.current = false;
// Stop any running spring before starting a new drag pan.stopAnimation();
translateX.stopAnimation(); startPageX.current = e.nativeEvent.pageX;
startPos.current = currentPos.current;
}, },
onPanResponderMove: (_, gs) => { onPanResponderMove: (e) => {
const maxX = trackWidthRef.current - HANDLE_SIZE - HANDLE_MARGIN * 2; const maxX = trackWidthRef.current - HANDLE_SIZE - HANDLE_PADDING * 2;
if (maxX <= 0) return; // safety — layout not ready if (maxX <= 0) return;
const raw = e.nativeEvent.pageX - startPageX.current;
const raw = startPos.current + gs.dx;
const clamped = Math.min(Math.max(raw, 0), maxX); const clamped = Math.min(Math.max(raw, 0), maxX);
currentPos.current = clamped; pan.setValue(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: () => { onPanResponderRelease: (e) => {
if (!confirmed.current) { const maxX = trackWidthRef.current - HANDLE_SIZE - HANDLE_PADDING * 2;
currentPos.current = 0; if (maxX <= 0) return;
Animated.spring(translateX, { const raw = e.nativeEvent.pageX - startPageX.current;
toValue: 0, const clamped = Math.min(Math.max(raw, 0), maxX);
useNativeDriver: false,
damping: 18, if (clamped / maxX >= THRESHOLD) {
stiffness: 220, confirmed.current = true;
}).start(({ finished }) => { snapToEnd();
if (finished) currentPos.current = 0; } else {
}); snapBack();
} }
}, },
onPanResponderTerminate: () => { onPanResponderTerminate: () => {
if (!confirmed.current) { if (!confirmed.current) snapBack();
currentPos.current = 0;
Animated.spring(translateX, {
toValue: 0,
useNativeDriver: false,
}).start(({ finished }) => {
if (finished) currentPos.current = 0;
});
}
}, },
}) })
).current; ).current;
const maxXForOpacity = Math.max(trackWidthState - HANDLE_SIZE - HANDLE_MARGIN * 2, 1); const maxXForFade = Math.max(trackWidthState - HANDLE_SIZE - HANDLE_PADDING * 2, 1);
const labelOpacity = translateX.interpolate({ const labelOpacity = pan.interpolate({
inputRange: [0, maxXForOpacity * 0.6], inputRange: [0, maxXForFade * 0.6],
outputRange: [1, 0], outputRange: [1, 0],
extrapolate: 'clamp', extrapolate: 'clamp',
}); });
const checkOpacity = pan.interpolate({
inputRange: [maxXForFade * 0.6, maxXForFade],
outputRange: [0, 1],
extrapolate: 'clamp',
});
const trackBg = color && !disabled ? color : undefined;
return ( return (
<View <View
style={[ style={[styles.track, disabled && styles.trackDisabled, trackBg ? { backgroundColor: trackBg } : undefined, style]}
styles.track,
disabled && styles.trackDisabled,
color && !disabled ? { backgroundColor: color } : undefined,
style,
]}
onLayout={e => { onLayout={e => {
const w = e.nativeEvent.layout.width; const w = e.nativeEvent.layout.width;
trackWidthRef.current = w; // PanResponder reads this trackWidthRef.current = w;
setTrackWidthState(w); // triggers re-render for labelOpacity setTrackWidthState(w);
}} }}
> >
<Animated.View {/* Label — fades out as handle moves right */}
style={[StyleSheet.absoluteFill, styles.labelWrap, { opacity: labelOpacity }]} <Animated.View style={[StyleSheet.absoluteFill, styles.labelWrap, { opacity: labelOpacity }]} pointerEvents="none">
pointerEvents="none"
>
<Text style={styles.label}>{label}</Text> <Text style={styles.label}>{label}</Text>
</Animated.View> </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 <Animated.View
style={[ style={[styles.handle, disabled && styles.handleDisabled, { transform: [{ translateX: pan }] }]}
styles.handle,
disabled && styles.handleDisabled,
{ transform: [{ translateX }] },
]}
{...panResponder.panHandlers} {...panResponder.panHandlers}
collapsable={false} collapsable={false}
> >
<Text style={styles.arrow}></Text> {isConfirmed
? <Text style={[styles.icon, styles.checkIcon]}></Text>
: <Text style={styles.icon}></Text>
}
</Animated.View> </Animated.View>
</View> </View>
); );
@@ -176,6 +204,10 @@ const styles = StyleSheet.create({
fontWeight: '600', fontWeight: '600',
letterSpacing: 0.3, letterSpacing: 0.3,
}, },
confirmedLabel: {
color: '#fff',
fontWeight: '700',
},
handle: { handle: {
width: HANDLE_SIZE, width: HANDLE_SIZE,
height: HANDLE_SIZE, height: HANDLE_SIZE,
@@ -183,7 +215,7 @@ const styles = StyleSheet.create({
backgroundColor: '#fff', backgroundColor: '#fff',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
marginLeft: HANDLE_MARGIN, marginLeft: HANDLE_PADDING,
shadowColor: '#000', shadowColor: '#000',
shadowOffset: { width: 0, height: 2 }, shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.18, shadowOpacity: 0.18,
@@ -191,10 +223,14 @@ const styles = StyleSheet.create({
elevation: 4, elevation: 4,
}, },
handleDisabled: { backgroundColor: '#E2E8F0' }, handleDisabled: { backgroundColor: '#E2E8F0' },
arrow: { icon: {
fontSize: 28, fontSize: 28,
color: '#0891B2', color: '#0891B2',
fontWeight: '800', fontWeight: '800',
lineHeight: 32, lineHeight: 32,
}, },
checkIcon: {
color: '#059669',
fontSize: 24,
},
}); });