fix: SlideToConfirm v4 — useNativeDriver:false, capture phase claim, pan.setValue in move

This commit is contained in:
Nemo
2026-03-25 06:29:14 +08:00
parent a3d40cd796
commit c83a25b153

View File

@@ -1,9 +1,12 @@
/** /**
* SlideToConfirm — pure RN, no RNGH, no Reanimated. * SlideToConfirm — pure RN, no RNGH, no Reanimated.
* *
* Key fix: Animated.event pipes gesture data directly to native thread * Key findings (Conan investigation):
* (no JS bridge delay) — this is what makes the handle actually track the finger. * - Animated.event + useNativeDriver:true is BROKEN with PanResponder — gestureState
* useNativeDriver: true on all animations for 60fps smoothness. * (dx/dy) is JS-computed so native driver skips setValue() entirely. Never use.
* - useNativeDriver: false is fine on New Arch (Fabric+JSI) — no bridge penalty.
* - onStartShouldSetPanResponderCapture claims gesture in capture phase BEFORE
* the Modal container can contest it on iOS — fixes the delayed-grant lag.
*/ */
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { import {
@@ -37,18 +40,16 @@ export function SlideToConfirm({
style, style,
color, color,
}: Props) { }: Props) {
// trackWidthRef — read inside PanResponder callbacks (no stale closure)
// trackWidthState — triggers re-render so opacity interpolation updates
const trackWidthRef = useRef(0); const trackWidthRef = useRef(0);
const [trackWidthState, setTrackWidthState] = useState(0); const [trackWidthState, setTrackWidthState] = useState(0);
// pan drives all animation — useNativeDriver: true for all animations // useNativeDriver: false — mandatory for PanResponder gestureState (dx/dy are JS-computed)
const pan = useRef(new Animated.Value(0)).current; const pan = useRef(new Animated.Value(0)).current;
const [isConfirmed, setIsConfirmed] = useState(false); const [isConfirmed, setIsConfirmed] = useState(false);
const confirmedRef = useRef(false); const confirmedRef = useRef(false);
// Live refs — break stale closures inside PanResponder // Live refs — break stale closures inside PanResponder (created once in useRef)
const disabledRef = useRef(disabled); const disabledRef = useRef(disabled);
disabledRef.current = disabled; disabledRef.current = disabled;
const onConfirmRef = useRef(onConfirm); const onConfirmRef = useRef(onConfirm);
@@ -60,7 +61,7 @@ export function SlideToConfirm({
const snapToEnd = () => { const snapToEnd = () => {
Animated.spring(pan, { Animated.spring(pan, {
toValue: getMaxX(), toValue: getMaxX(),
useNativeDriver: true, useNativeDriver: false,
damping: 18, damping: 18,
stiffness: 220, stiffness: 220,
}).start(({ finished }) => { }).start(({ finished }) => {
@@ -75,7 +76,7 @@ export function SlideToConfirm({
Animated.timing(pan, { Animated.timing(pan, {
toValue: 0, toValue: 0,
duration: SNAP_DURATION, duration: SNAP_DURATION,
useNativeDriver: true, useNativeDriver: false,
}).start(({ finished }) => { }).start(({ finished }) => {
if (finished && !disabledRef.current) runTip(); if (finished && !disabledRef.current) runTip();
}); });
@@ -84,8 +85,8 @@ export function SlideToConfirm({
const runTip = () => { const runTip = () => {
if (disabledRef.current) return; if (disabledRef.current) return;
Animated.sequence([ Animated.sequence([
Animated.timing(pan, { toValue: TIP_DISTANCE, duration: TIP_DURATION, useNativeDriver: true }), Animated.timing(pan, { toValue: TIP_DISTANCE, duration: TIP_DURATION, useNativeDriver: false }),
Animated.timing(pan, { toValue: 0, duration: TIP_DURATION, useNativeDriver: true }), Animated.timing(pan, { toValue: 0, duration: TIP_DURATION, useNativeDriver: false }),
]).start(); ]).start();
}; };
@@ -96,10 +97,12 @@ export function SlideToConfirm({
const panResponder = useRef( const panResponder = useRef(
PanResponder.create({ PanResponder.create({
// Claim gesture on touch start — ensures dx=0 at grant, no jump // Capture phase — claims gesture BEFORE Modal container can contest it on iOS
// This prevents the delayed-grant that made v1/v2 appear laggy
onStartShouldSetPanResponderCapture: () =>
!disabledRef.current && trackWidthRef.current > 0,
onStartShouldSetPanResponder: () => onStartShouldSetPanResponder: () =>
!disabledRef.current && trackWidthRef.current > 0, !disabledRef.current && trackWidthRef.current > 0,
// Also claim on move (horizontal dominant) as fallback
onMoveShouldSetPanResponder: (_, gs) => onMoveShouldSetPanResponder: (_, gs) =>
!disabledRef.current && !disabledRef.current &&
trackWidthRef.current > 0 && trackWidthRef.current > 0 &&
@@ -108,14 +111,14 @@ export function SlideToConfirm({
onPanResponderGrant: () => { onPanResponderGrant: () => {
confirmedRef.current = false; confirmedRef.current = false;
pan.stopAnimation(); pan.stopAnimation();
pan.setValue(0); // reset — Animated.event maps dx from 0 pan.setValue(0);
}, },
// Animated.event pipes dx directly to native thread — true 1:1 finger tracking onPanResponderMove: (_, gs) => {
onPanResponderMove: Animated.event( const maxX = getMaxX();
[null, { dx: pan }], const clamped = Math.min(Math.max(gs.dx, 0), maxX);
{ useNativeDriver: true } pan.setValue(clamped);
), },
onPanResponderRelease: (_, gs) => { onPanResponderRelease: (_, gs) => {
const maxX = getMaxX(); const maxX = getMaxX();
@@ -145,7 +148,6 @@ export function SlideToConfirm({
outputRange: [0, 1], outputRange: [0, 1],
extrapolate: 'clamp', extrapolate: 'clamp',
}); });
// Clamp translateX visually so handle doesn't overshoot track
const clampedX = pan.interpolate({ const clampedX = pan.interpolate({
inputRange: [0, maxX], inputRange: [0, maxX],
outputRange: [0, maxX], outputRange: [0, maxX],
@@ -168,7 +170,6 @@ export function SlideToConfirm({
setTrackWidthState(w); setTrackWidthState(w);
}} }}
> >
{/* "Slide to confirm" label — fades out as handle moves right */}
<Animated.View <Animated.View
style={[StyleSheet.absoluteFill, styles.center, { opacity: labelOpacity }]} style={[StyleSheet.absoluteFill, styles.center, { opacity: labelOpacity }]}
pointerEvents="none" pointerEvents="none"
@@ -176,7 +177,6 @@ export function SlideToConfirm({
<Text style={styles.label}>{label}</Text> <Text style={styles.label}>{label}</Text>
</Animated.View> </Animated.View>
{/* "Confirmed ✓" label — fades in near end */}
<Animated.View <Animated.View
style={[StyleSheet.absoluteFill, styles.center, { opacity: checkOpacity }]} style={[StyleSheet.absoluteFill, styles.center, { opacity: checkOpacity }]}
pointerEvents="none" pointerEvents="none"
@@ -184,7 +184,6 @@ export function SlideToConfirm({
<Text style={[styles.label, styles.confirmedLabel]}>Confirmed </Text> <Text style={[styles.label, styles.confirmedLabel]}>Confirmed </Text>
</Animated.View> </Animated.View>
{/* Sliding handle — driven by Animated.event (native thread) */}
<Animated.View <Animated.View
style={[ style={[
styles.handle, styles.handle,
@@ -219,10 +218,7 @@ const styles = StyleSheet.create({
fontWeight: '600', fontWeight: '600',
letterSpacing: 0.3, letterSpacing: 0.3,
}, },
confirmedLabel: { confirmedLabel: { color: '#fff', fontWeight: '700' },
color: '#fff',
fontWeight: '700',
},
handle: { handle: {
width: HANDLE_SIZE, width: HANDLE_SIZE,
height: HANDLE_SIZE, height: HANDLE_SIZE,
@@ -244,8 +240,5 @@ const styles = StyleSheet.create({
fontWeight: '800', fontWeight: '800',
lineHeight: 32, lineHeight: 32,
}, },
checkIcon: { checkIcon: { color: '#059669', fontSize: 24 },
color: '#059669',
fontSize: 24,
},
}); });