fix: SlideToConfirm use Animated.event + useNativeDriver:true for real-time finger tracking
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user