fix: SlideToConfirm use Animated.event + useNativeDriver:true for real-time finger tracking

This commit is contained in:
Nemo
2026-03-25 06:24:13 +08:00
parent bf1e36f9e7
commit a3d40cd796

View File

@@ -1,14 +1,9 @@
/** /**
* SlideToConfirm — pure RN, no RNGH, no Reanimated. * SlideToConfirm — pure RN, no RNGH, no Reanimated.
* Approach ported from rn-slide-to-confirm (PanResponder + pageX tracking).
* *
* Features: * Key fix: Animated.event pipes gesture data directly to native thread
* - Tip bounce animation on mount (hints it's slideable) * (no JS bridge delay) — this is what makes the handle actually track the finger.
* - Smooth finger tracking via pageX - startPoint * useNativeDriver: true on all animations for 60fps smoothness.
* - Threshold check on release (not mid-drag)
* - Chevron → Checkmark icon on confirm
* - disabledRef + onConfirmRef break stale closures
* - trackWidthRef + trackWidthState: layout guard before gesture
*/ */
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { import {
@@ -31,9 +26,9 @@ interface Props {
const HANDLE_SIZE = 56; const HANDLE_SIZE = 56;
const HANDLE_PADDING = 3; const HANDLE_PADDING = 3;
const THRESHOLD = 0.80; const THRESHOLD = 0.80;
const TIP_DISTANCE = 30; const TIP_DISTANCE = 28;
const TIP_DURATION = 280; const TIP_DURATION = 260;
const SNAP_DURATION = 280; const SNAP_DURATION = 260;
export function SlideToConfirm({ export function SlideToConfirm({
onConfirm, onConfirm,
@@ -42,40 +37,30 @@ export function SlideToConfirm({
style, style,
color, color,
}: Props) { }: Props) {
// Track width — ref for PanResponder (no stale closure), state for re-render // 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);
const pan = useRef(new Animated.Value(0)).current; // pan drives all animation — useNativeDriver: true for all animations
const startPageX = useRef(0); const pan = useRef(new Animated.Value(0)).current;
const confirmed = useRef(false);
const [isConfirmed, setIsConfirmed] = useState(false);
// Live refs — break stale closures const [isConfirmed, setIsConfirmed] = useState(false);
const confirmedRef = useRef(false);
// Live refs — break stale closures inside PanResponder
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 getMaxX = () =>
const runTip = () => { Math.max(trackWidthRef.current - HANDLE_SIZE - HANDLE_PADDING * 2, 1);
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 snapToEnd = () => {
const maxX = trackWidthRef.current - HANDLE_SIZE - HANDLE_PADDING * 2;
Animated.spring(pan, { Animated.spring(pan, {
toValue: maxX, toValue: getMaxX(),
useNativeDriver: false, useNativeDriver: true,
damping: 18, damping: 18,
stiffness: 220, stiffness: 220,
}).start(({ finished }) => { }).start(({ finished }) => {
@@ -90,44 +75,53 @@ export function SlideToConfirm({
Animated.timing(pan, { Animated.timing(pan, {
toValue: 0, toValue: 0,
duration: SNAP_DURATION, duration: SNAP_DURATION,
useNativeDriver: false, useNativeDriver: true,
}).start(({ finished }) => { }).start(({ finished }) => {
if (finished && !disabledRef.current) runTip(); if (finished && !disabledRef.current) runTip();
}); });
}; };
const runTip = () => {
if (disabledRef.current) return;
Animated.sequence([
Animated.timing(pan, { toValue: TIP_DISTANCE, duration: TIP_DURATION, useNativeDriver: true }),
Animated.timing(pan, { toValue: 0, duration: TIP_DURATION, useNativeDriver: true }),
]).start();
};
useEffect(() => {
const t = setTimeout(runTip, 700);
return () => clearTimeout(t);
}, []);
const panResponder = useRef( const panResponder = useRef(
PanResponder.create({ PanResponder.create({
// Block until layout measured // Claim gesture on touch start — ensures dx=0 at grant, no jump
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 &&
Math.abs(gs.dx) > Math.abs(gs.dy) * 1.5, Math.abs(gs.dx) > Math.abs(gs.dy),
onPanResponderGrant: (e) => { onPanResponderGrant: () => {
confirmed.current = false; confirmedRef.current = false;
pan.stopAnimation(); pan.stopAnimation();
startPageX.current = e.nativeEvent.pageX; pan.setValue(0); // reset — Animated.event maps dx from 0
}, },
onPanResponderMove: (e) => { // Animated.event pipes dx directly to native thread — true 1:1 finger tracking
const maxX = trackWidthRef.current - HANDLE_SIZE - HANDLE_PADDING * 2; onPanResponderMove: Animated.event(
if (maxX <= 0) return; [null, { dx: pan }],
const raw = e.nativeEvent.pageX - startPageX.current; { useNativeDriver: true }
const clamped = Math.min(Math.max(raw, 0), maxX); ),
pan.setValue(clamped);
},
onPanResponderRelease: (e) => { onPanResponderRelease: (_, gs) => {
const maxX = trackWidthRef.current - HANDLE_SIZE - HANDLE_PADDING * 2; const maxX = getMaxX();
if (maxX <= 0) return; const clamped = Math.min(Math.max(gs.dx, 0), maxX);
const raw = e.nativeEvent.pageX - startPageX.current; if (!confirmedRef.current && clamped / maxX >= THRESHOLD) {
const clamped = Math.min(Math.max(raw, 0), maxX); confirmedRef.current = true;
if (clamped / maxX >= THRESHOLD) {
confirmed.current = true;
snapToEnd(); snapToEnd();
} else { } else {
snapBack(); snapBack();
@@ -135,47 +129,68 @@ export function SlideToConfirm({
}, },
onPanResponderTerminate: () => { onPanResponderTerminate: () => {
if (!confirmed.current) snapBack(); if (!confirmedRef.current) snapBack();
}, },
}) })
).current; ).current;
const maxXForFade = Math.max(trackWidthState - HANDLE_SIZE - HANDLE_PADDING * 2, 1); const maxX = Math.max(trackWidthState - HANDLE_SIZE - HANDLE_PADDING * 2, 1);
const labelOpacity = pan.interpolate({ const labelOpacity = pan.interpolate({
inputRange: [0, maxXForFade * 0.6], inputRange: [0, maxX * 0.5],
outputRange: [1, 0], outputRange: [1, 0],
extrapolate: 'clamp', extrapolate: 'clamp',
}); });
const checkOpacity = pan.interpolate({ const checkOpacity = pan.interpolate({
inputRange: [maxXForFade * 0.6, maxXForFade], inputRange: [maxX * 0.6, maxX],
outputRange: [0, 1], outputRange: [0, 1],
extrapolate: 'clamp', extrapolate: 'clamp',
}); });
// Clamp translateX visually so handle doesn't overshoot track
const clampedX = pan.interpolate({
inputRange: [0, maxX],
outputRange: [0, maxX],
extrapolate: 'clamp',
});
const trackBg = color && !disabled ? color : undefined; const trackColor = color && !disabled ? color : undefined;
return ( return (
<View <View
style={[styles.track, disabled && styles.trackDisabled, trackBg ? { backgroundColor: trackBg } : undefined, style]} style={[
styles.track,
disabled && styles.trackDisabled,
trackColor ? { backgroundColor: trackColor } : undefined,
style,
]}
onLayout={e => { onLayout={e => {
const w = e.nativeEvent.layout.width; const w = e.nativeEvent.layout.width;
trackWidthRef.current = w; trackWidthRef.current = w;
setTrackWidthState(w); setTrackWidthState(w);
}} }}
> >
{/* Label — fades out as handle moves right */} {/* "Slide to confirm" label — fades out as handle moves right */}
<Animated.View style={[StyleSheet.absoluteFill, styles.labelWrap, { opacity: labelOpacity }]} pointerEvents="none"> <Animated.View
style={[StyleSheet.absoluteFill, styles.center, { opacity: labelOpacity }]}
pointerEvents="none"
>
<Text style={styles.label}>{label}</Text> <Text style={styles.label}>{label}</Text>
</Animated.View> </Animated.View>
{/* "Confirmed" label — fades in near end */} {/* "Confirmed" label — fades in near end */}
<Animated.View style={[StyleSheet.absoluteFill, styles.labelWrap, { opacity: checkOpacity }]} pointerEvents="none"> <Animated.View
style={[StyleSheet.absoluteFill, styles.center, { opacity: checkOpacity }]}
pointerEvents="none"
>
<Text style={[styles.label, styles.confirmedLabel]}>Confirmed </Text> <Text style={[styles.label, styles.confirmedLabel]}>Confirmed </Text>
</Animated.View> </Animated.View>
{/* Sliding handle */} {/* Sliding handle — driven by Animated.event (native thread) */}
<Animated.View <Animated.View
style={[styles.handle, disabled && styles.handleDisabled, { transform: [{ translateX: pan }] }]} style={[
styles.handle,
disabled && styles.handleDisabled,
{ transform: [{ translateX: clampedX }] },
]}
{...panResponder.panHandlers} {...panResponder.panHandlers}
collapsable={false} collapsable={false}
> >
@@ -197,7 +212,7 @@ const styles = StyleSheet.create({
overflow: 'hidden', overflow: 'hidden',
}, },
trackDisabled: { backgroundColor: '#94A3B8' }, trackDisabled: { backgroundColor: '#94A3B8' },
labelWrap: { alignItems: 'center', justifyContent: 'center' }, center: { alignItems: 'center', justifyContent: 'center' },
label: { label: {
color: 'rgba(255,255,255,0.85)', color: 'rgba(255,255,255,0.85)',
fontSize: 15, fontSize: 15,