feat: SlideToConfirm v3 — pageX tracking, tip bounce, check icon, threshold on release
This commit is contained in:
@@ -1,13 +1,16 @@
|
||||
/**
|
||||
* SlideToConfirm — pure RN, no RNGH, no Reanimated.
|
||||
* PanResponder on handle + Animated(useNativeDriver:false).
|
||||
* Approach ported from rn-slide-to-confirm (PanResponder + pageX tracking).
|
||||
*
|
||||
* Fix history:
|
||||
* - 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
|
||||
* Features:
|
||||
* - Tip bounce animation on mount (hints it's slideable)
|
||||
* - Smooth finger tracking via pageX - startPoint
|
||||
* - Threshold check on release (not mid-drag)
|
||||
* - Chevron → Checkmark icon on confirm
|
||||
* - disabledRef + onConfirmRef break stale closures
|
||||
* - trackWidthRef + trackWidthState: layout guard before gesture
|
||||
*/
|
||||
import { useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Animated,
|
||||
PanResponder,
|
||||
@@ -26,135 +29,160 @@ interface Props {
|
||||
}
|
||||
|
||||
const HANDLE_SIZE = 56;
|
||||
const HANDLE_MARGIN = 3;
|
||||
const HANDLE_PADDING = 3;
|
||||
const THRESHOLD = 0.80;
|
||||
const TIP_DISTANCE = 30;
|
||||
const TIP_DURATION = 280;
|
||||
const SNAP_DURATION = 280;
|
||||
|
||||
export function SlideToConfirm({ onConfirm, label = 'Slide to confirm', disabled = false, style, color }: Props) {
|
||||
// trackWidthRef — read inside PanResponder (no stale closure)
|
||||
// trackWidthState — triggers re-render so labelOpacity interpolation updates
|
||||
export function SlideToConfirm({
|
||||
onConfirm,
|
||||
label = 'Slide to confirm',
|
||||
disabled = false,
|
||||
style,
|
||||
color,
|
||||
}: Props) {
|
||||
// Track width — ref for PanResponder (no stale closure), state for re-render
|
||||
const trackWidthRef = useRef(0);
|
||||
const [trackWidthState, setTrackWidthState] = useState(0);
|
||||
|
||||
const pan = useRef(new Animated.Value(0)).current;
|
||||
const startPageX = useRef(0);
|
||||
const confirmed = useRef(false);
|
||||
const translateX = useRef(new Animated.Value(0)).current;
|
||||
const [isConfirmed, setIsConfirmed] = useState(false);
|
||||
|
||||
// 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 ──────────────────────────────────────
|
||||
// Live refs — break stale closures
|
||||
const disabledRef = useRef(disabled);
|
||||
disabledRef.current = disabled;
|
||||
const onConfirmRef = useRef(onConfirm);
|
||||
onConfirmRef.current = onConfirm;
|
||||
|
||||
const panResponder = useRef(
|
||||
PanResponder.create({
|
||||
// Block gestures until layout has been measured
|
||||
onStartShouldSetPanResponder: () =>
|
||||
!disabledRef.current && trackWidthRef.current > 0,
|
||||
// Horizontal-dominance — prevents ScrollView stealing on iOS
|
||||
onMoveShouldSetPanResponder: (_, gs) =>
|
||||
!disabledRef.current &&
|
||||
trackWidthRef.current > 0 &&
|
||||
Math.abs(gs.dx) > Math.abs(gs.dy) * 2,
|
||||
// Tip bounce on mount
|
||||
const runTip = () => {
|
||||
Animated.sequence([
|
||||
Animated.timing(pan, { toValue: TIP_DISTANCE, duration: TIP_DURATION, useNativeDriver: false }),
|
||||
Animated.timing(pan, { toValue: 0, duration: TIP_DURATION, useNativeDriver: false }),
|
||||
]).start();
|
||||
};
|
||||
|
||||
onPanResponderGrant: () => {
|
||||
confirmed.current = false;
|
||||
// Stop any running spring before starting a new drag
|
||||
translateX.stopAnimation();
|
||||
startPos.current = currentPos.current;
|
||||
},
|
||||
useEffect(() => {
|
||||
// Wait for layout then bounce hint
|
||||
const t = setTimeout(runTip, 600);
|
||||
return () => clearTimeout(t);
|
||||
}, []);
|
||||
|
||||
onPanResponderMove: (_, gs) => {
|
||||
const maxX = trackWidthRef.current - HANDLE_SIZE - HANDLE_MARGIN * 2;
|
||||
if (maxX <= 0) return; // safety — layout not ready
|
||||
|
||||
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) {
|
||||
confirmed.current = true;
|
||||
Animated.spring(translateX, {
|
||||
const snapToEnd = () => {
|
||||
const maxX = trackWidthRef.current - HANDLE_SIZE - HANDLE_PADDING * 2;
|
||||
Animated.spring(pan, {
|
||||
toValue: maxX,
|
||||
useNativeDriver: false,
|
||||
damping: 18,
|
||||
stiffness: 220,
|
||||
}).start(({ finished }) => {
|
||||
if (finished) currentPos.current = maxX;
|
||||
});
|
||||
if (finished) {
|
||||
setIsConfirmed(true);
|
||||
onConfirmRef.current();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const snapBack = () => {
|
||||
Animated.timing(pan, {
|
||||
toValue: 0,
|
||||
duration: SNAP_DURATION,
|
||||
useNativeDriver: false,
|
||||
}).start(({ finished }) => {
|
||||
if (finished && !disabledRef.current) runTip();
|
||||
});
|
||||
};
|
||||
|
||||
const panResponder = useRef(
|
||||
PanResponder.create({
|
||||
// Block until layout measured
|
||||
onStartShouldSetPanResponder: () =>
|
||||
!disabledRef.current && trackWidthRef.current > 0,
|
||||
onMoveShouldSetPanResponder: (_, gs) =>
|
||||
!disabledRef.current &&
|
||||
trackWidthRef.current > 0 &&
|
||||
Math.abs(gs.dx) > Math.abs(gs.dy) * 1.5,
|
||||
|
||||
onPanResponderGrant: (e) => {
|
||||
confirmed.current = false;
|
||||
pan.stopAnimation();
|
||||
startPageX.current = e.nativeEvent.pageX;
|
||||
},
|
||||
|
||||
onPanResponderRelease: () => {
|
||||
if (!confirmed.current) {
|
||||
currentPos.current = 0;
|
||||
Animated.spring(translateX, {
|
||||
toValue: 0,
|
||||
useNativeDriver: false,
|
||||
damping: 18,
|
||||
stiffness: 220,
|
||||
}).start(({ finished }) => {
|
||||
if (finished) currentPos.current = 0;
|
||||
});
|
||||
onPanResponderMove: (e) => {
|
||||
const maxX = trackWidthRef.current - HANDLE_SIZE - HANDLE_PADDING * 2;
|
||||
if (maxX <= 0) return;
|
||||
const raw = e.nativeEvent.pageX - startPageX.current;
|
||||
const clamped = Math.min(Math.max(raw, 0), maxX);
|
||||
pan.setValue(clamped);
|
||||
},
|
||||
|
||||
onPanResponderRelease: (e) => {
|
||||
const maxX = trackWidthRef.current - HANDLE_SIZE - HANDLE_PADDING * 2;
|
||||
if (maxX <= 0) return;
|
||||
const raw = e.nativeEvent.pageX - startPageX.current;
|
||||
const clamped = Math.min(Math.max(raw, 0), maxX);
|
||||
|
||||
if (clamped / maxX >= THRESHOLD) {
|
||||
confirmed.current = true;
|
||||
snapToEnd();
|
||||
} else {
|
||||
snapBack();
|
||||
}
|
||||
},
|
||||
|
||||
onPanResponderTerminate: () => {
|
||||
if (!confirmed.current) {
|
||||
currentPos.current = 0;
|
||||
Animated.spring(translateX, {
|
||||
toValue: 0,
|
||||
useNativeDriver: false,
|
||||
}).start(({ finished }) => {
|
||||
if (finished) currentPos.current = 0;
|
||||
});
|
||||
}
|
||||
if (!confirmed.current) snapBack();
|
||||
},
|
||||
})
|
||||
).current;
|
||||
|
||||
const maxXForOpacity = Math.max(trackWidthState - HANDLE_SIZE - HANDLE_MARGIN * 2, 1);
|
||||
const labelOpacity = translateX.interpolate({
|
||||
inputRange: [0, maxXForOpacity * 0.6],
|
||||
const maxXForFade = Math.max(trackWidthState - HANDLE_SIZE - HANDLE_PADDING * 2, 1);
|
||||
const labelOpacity = pan.interpolate({
|
||||
inputRange: [0, maxXForFade * 0.6],
|
||||
outputRange: [1, 0],
|
||||
extrapolate: 'clamp',
|
||||
});
|
||||
const checkOpacity = pan.interpolate({
|
||||
inputRange: [maxXForFade * 0.6, maxXForFade],
|
||||
outputRange: [0, 1],
|
||||
extrapolate: 'clamp',
|
||||
});
|
||||
|
||||
const trackBg = color && !disabled ? color : undefined;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.track,
|
||||
disabled && styles.trackDisabled,
|
||||
color && !disabled ? { backgroundColor: color } : undefined,
|
||||
style,
|
||||
]}
|
||||
style={[styles.track, disabled && styles.trackDisabled, trackBg ? { backgroundColor: trackBg } : undefined, style]}
|
||||
onLayout={e => {
|
||||
const w = e.nativeEvent.layout.width;
|
||||
trackWidthRef.current = w; // PanResponder reads this
|
||||
setTrackWidthState(w); // triggers re-render for labelOpacity
|
||||
trackWidthRef.current = w;
|
||||
setTrackWidthState(w);
|
||||
}}
|
||||
>
|
||||
<Animated.View
|
||||
style={[StyleSheet.absoluteFill, styles.labelWrap, { opacity: labelOpacity }]}
|
||||
pointerEvents="none"
|
||||
>
|
||||
{/* Label — fades out as handle moves right */}
|
||||
<Animated.View style={[StyleSheet.absoluteFill, styles.labelWrap, { opacity: labelOpacity }]} pointerEvents="none">
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
</Animated.View>
|
||||
|
||||
{/* "Confirmed" label — fades in near end */}
|
||||
<Animated.View style={[StyleSheet.absoluteFill, styles.labelWrap, { opacity: checkOpacity }]} pointerEvents="none">
|
||||
<Text style={[styles.label, styles.confirmedLabel]}>Confirmed ✓</Text>
|
||||
</Animated.View>
|
||||
|
||||
{/* Sliding handle */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.handle,
|
||||
disabled && styles.handleDisabled,
|
||||
{ transform: [{ translateX }] },
|
||||
]}
|
||||
style={[styles.handle, disabled && styles.handleDisabled, { transform: [{ translateX: pan }] }]}
|
||||
{...panResponder.panHandlers}
|
||||
collapsable={false}
|
||||
>
|
||||
<Text style={styles.arrow}>›</Text>
|
||||
{isConfirmed
|
||||
? <Text style={[styles.icon, styles.checkIcon]}>✓</Text>
|
||||
: <Text style={styles.icon}>›</Text>
|
||||
}
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
@@ -176,6 +204,10 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
confirmedLabel: {
|
||||
color: '#fff',
|
||||
fontWeight: '700',
|
||||
},
|
||||
handle: {
|
||||
width: HANDLE_SIZE,
|
||||
height: HANDLE_SIZE,
|
||||
@@ -183,7 +215,7 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: '#fff',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginLeft: HANDLE_MARGIN,
|
||||
marginLeft: HANDLE_PADDING,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.18,
|
||||
@@ -191,10 +223,14 @@ const styles = StyleSheet.create({
|
||||
elevation: 4,
|
||||
},
|
||||
handleDisabled: { backgroundColor: '#E2E8F0' },
|
||||
arrow: {
|
||||
icon: {
|
||||
fontSize: 28,
|
||||
color: '#0891B2',
|
||||
fontWeight: '800',
|
||||
lineHeight: 32,
|
||||
},
|
||||
checkIcon: {
|
||||
color: '#059669',
|
||||
fontSize: 24,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user