fix: SlideToConfirm snap-to-end — trackWidthRef+State, no _value/_offset private API, layout guard

This commit is contained in:
Nemo
2026-03-25 05:57:33 +08:00
parent 295c32e6e7
commit b431d83d13

View File

@@ -2,10 +2,12 @@
* SlideToConfirm — pure RN, no RNGH, no Reanimated. * SlideToConfirm — pure RN, no RNGH, no Reanimated.
* PanResponder on handle + Animated(useNativeDriver:false). * PanResponder on handle + Animated(useNativeDriver:false).
* *
* Fix: live refs break stale closures — disabledRef + onConfirmRef * Fix history:
* updated on every render so PanResponder always reads current values. * - 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 } from 'react'; import { useRef, useState } from 'react';
import { import {
Animated, Animated,
PanResponder, PanResponder,
@@ -24,88 +26,131 @@ interface Props {
} }
const HANDLE_SIZE = 56; const HANDLE_SIZE = 56;
const HANDLE_MARGIN = 3;
const THRESHOLD = 0.80; const THRESHOLD = 0.80;
export function SlideToConfirm({ onConfirm, label = 'Slide to confirm', disabled = false, style, color }: Props) { export function SlideToConfirm({ onConfirm, label = 'Slide to confirm', disabled = false, style, color }: Props) {
const trackWidth = useRef(0); // 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 confirmed = useRef(false);
const translateX = useRef(new Animated.Value(0)).current; const translateX = useRef(new Animated.Value(0)).current;
// ── Live refs: break stale closures inside PanResponder ────────────────── // 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); const disabledRef = useRef(disabled);
disabledRef.current = disabled; // updated on every render disabledRef.current = disabled;
const onConfirmRef = useRef(onConfirm); const onConfirmRef = useRef(onConfirm);
onConfirmRef.current = onConfirm; // updated on every render onConfirmRef.current = onConfirm;
const panResponder = useRef( const panResponder = useRef(
PanResponder.create({ PanResponder.create({
onStartShouldSetPanResponder: () => !disabledRef.current, // Block gestures until layout has been measured
// Horizontal-dominance check prevents ScrollView stealing vertical-ish drags on iOS onStartShouldSetPanResponder: () =>
!disabledRef.current && trackWidthRef.current > 0,
// Horizontal-dominance — prevents ScrollView stealing on iOS
onMoveShouldSetPanResponder: (_, gs) => onMoveShouldSetPanResponder: (_, gs) =>
!disabledRef.current && Math.abs(gs.dx) > Math.abs(gs.dy) * 2, !disabledRef.current &&
trackWidthRef.current > 0 &&
Math.abs(gs.dx) > Math.abs(gs.dy) * 2,
onPanResponderGrant: () => { onPanResponderGrant: () => {
confirmed.current = false; confirmed.current = false;
(translateX as any).setOffset((translateX as any)._value); // Stop any running spring before starting a new drag
translateX.setValue(0); translateX.stopAnimation();
startPos.current = currentPos.current;
}, },
onPanResponderMove: (_, gs) => { onPanResponderMove: (_, gs) => {
const maxX = Math.max(trackWidth.current - HANDLE_SIZE - 6, 1); const maxX = trackWidthRef.current - HANDLE_SIZE - HANDLE_MARGIN * 2;
const clamped = Math.min(Math.max(gs.dx, 0), maxX); if (maxX <= 0) return; // safety — layout not ready
translateX.setValue(clamped - (translateX as any)._offset);
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) { if (!confirmed.current && clamped / maxX >= THRESHOLD) {
confirmed.current = true; confirmed.current = true;
(translateX as any).flattenOffset();
Animated.spring(translateX, { Animated.spring(translateX, {
toValue: maxX, toValue: maxX,
useNativeDriver: false, useNativeDriver: false,
damping: 18, damping: 18,
stiffness: 220, stiffness: 220,
}).start(); }).start(({ finished }) => {
onConfirmRef.current(); // always calls latest onConfirm if (finished) currentPos.current = maxX;
});
onConfirmRef.current();
} }
}, },
onPanResponderRelease: () => { onPanResponderRelease: () => {
(translateX as any).flattenOffset();
if (!confirmed.current) { if (!confirmed.current) {
currentPos.current = 0;
Animated.spring(translateX, { Animated.spring(translateX, {
toValue: 0, toValue: 0,
useNativeDriver: false, useNativeDriver: false,
damping: 18, damping: 18,
stiffness: 220, stiffness: 220,
}).start(); }).start(({ finished }) => {
if (finished) currentPos.current = 0;
});
} }
}, },
onPanResponderTerminate: () => { onPanResponderTerminate: () => {
(translateX as any).flattenOffset();
if (!confirmed.current) { if (!confirmed.current) {
currentPos.current = 0;
Animated.spring(translateX, { Animated.spring(translateX, {
toValue: 0, toValue: 0,
useNativeDriver: false, useNativeDriver: false,
}).start(); }).start(({ finished }) => {
if (finished) currentPos.current = 0;
});
} }
}, },
}) })
).current; ).current;
const maxXForOpacity = Math.max(trackWidth.current - HANDLE_SIZE - 6, 1); const maxXForOpacity = Math.max(trackWidthState - HANDLE_SIZE - HANDLE_MARGIN * 2, 1);
const labelOpacity = translateX.interpolate({ const labelOpacity = translateX.interpolate({
inputRange: [0, maxXForOpacity > 0 ? maxXForOpacity * 0.6 : 1], inputRange: [0, maxXForOpacity * 0.6],
outputRange: [1, 0], outputRange: [1, 0],
extrapolate: 'clamp', extrapolate: 'clamp',
}); });
return ( return (
<View <View
style={[styles.track, disabled && styles.trackDisabled, color && !disabled ? { backgroundColor: color } : undefined, style]} style={[
onLayout={e => { trackWidth.current = e.nativeEvent.layout.width; }} 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"
> >
<Animated.View style={[StyleSheet.absoluteFill, styles.labelWrap, { opacity: labelOpacity }]} pointerEvents="none">
<Text style={styles.label}>{label}</Text> <Text style={styles.label}>{label}</Text>
</Animated.View> </Animated.View>
<Animated.View <Animated.View
style={[styles.handle, disabled && styles.handleDisabled, { transform: [{ translateX }] }]} style={[
styles.handle,
disabled && styles.handleDisabled,
{ transform: [{ translateX }] },
]}
{...panResponder.panHandlers} {...panResponder.panHandlers}
collapsable={false} collapsable={false}
> >
@@ -138,7 +183,7 @@ const styles = StyleSheet.create({
backgroundColor: '#fff', backgroundColor: '#fff',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
marginLeft: 3, marginLeft: HANDLE_MARGIN,
shadowColor: '#000', shadowColor: '#000',
shadowOffset: { width: 0, height: 2 }, shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.18, shadowOpacity: 0.18,