fix: SlideToConfirm snap-to-end — trackWidthRef+State, no _value/_offset private API, layout guard
This commit is contained in:
@@ -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,
|
||||||
@@ -23,89 +25,132 @@ interface Props {
|
|||||||
color?: string;
|
color?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HANDLE_SIZE = 56;
|
const HANDLE_SIZE = 56;
|
||||||
const THRESHOLD = 0.80;
|
const HANDLE_MARGIN = 3;
|
||||||
|
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)
|
||||||
const confirmed = useRef(false);
|
// trackWidthState — triggers re-render so labelOpacity interpolation updates
|
||||||
const translateX = useRef(new Animated.Value(0)).current;
|
const trackWidthRef = useRef(0);
|
||||||
|
const [trackWidthState, setTrackWidthState] = useState(0);
|
||||||
|
|
||||||
// ── Live refs: break stale closures inside PanResponder ──────────────────
|
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);
|
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,
|
||||||
|
|||||||
Reference in New Issue
Block a user