fix: SlideToConfirm v4 — useNativeDriver:false, capture phase claim, pan.setValue in move
This commit is contained in:
@@ -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,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user