fix: rewrite SlideToConfirm with onTouchStart/Move/End instead of PanResponder - works in Modals
This commit is contained in:
@@ -1,10 +1,10 @@
|
|||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { View, Text, PanResponder, Animated, StyleSheet } from 'react-native';
|
import { View, Text, Animated, StyleSheet } from 'react-native';
|
||||||
|
|
||||||
const TRACK_HEIGHT = 58;
|
const TRACK_HEIGHT = 60;
|
||||||
const HANDLE_SIZE = 46;
|
const HANDLE_SIZE = 48;
|
||||||
const PADDING = 6;
|
const PADDING = 6;
|
||||||
const THRESHOLD = 0.85; // 85% of track = confirmed
|
const THRESHOLD = 0.82;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
label?: string;
|
label?: string;
|
||||||
@@ -14,42 +14,43 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SlideToConfirm({ label = 'Slide to confirm', color = '#059669', onConfirm, disabled = false }: Props) {
|
export function SlideToConfirm({ label = 'Slide to confirm', color = '#059669', onConfirm, disabled = false }: Props) {
|
||||||
const pan = useRef(new Animated.Value(0)).current;
|
|
||||||
const [done, setDone] = useState(false);
|
|
||||||
const [trackW, setTrackW] = useState(0);
|
const [trackW, setTrackW] = useState(0);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
const translateX = useRef(new Animated.Value(0)).current;
|
||||||
|
const startX = useRef(0);
|
||||||
|
const currentX = useRef(0);
|
||||||
|
|
||||||
const maxX = trackW - HANDLE_SIZE - PADDING * 2;
|
const maxX = Math.max(trackW - HANDLE_SIZE - PADDING * 2, 1);
|
||||||
|
|
||||||
const panResponder = useRef(
|
const onTouchStart = (e: any) => {
|
||||||
PanResponder.create({
|
if (disabled || done) return;
|
||||||
onStartShouldSetPanResponder: () => !disabled && !done,
|
startX.current = e.nativeEvent.pageX;
|
||||||
onStartShouldSetPanResponderCapture: () => !disabled && !done,
|
};
|
||||||
onMoveShouldSetPanResponder: () => !disabled && !done,
|
|
||||||
onMoveShouldSetPanResponderCapture: () => !disabled && !done,
|
const onTouchMove = (e: any) => {
|
||||||
onPanResponderMove: (_, gs) => {
|
if (disabled || done) return;
|
||||||
const x = Math.max(0, Math.min(gs.dx, maxX));
|
const dx = e.nativeEvent.pageX - startX.current;
|
||||||
pan.setValue(x);
|
const x = Math.max(0, Math.min(dx, maxX));
|
||||||
},
|
currentX.current = x;
|
||||||
onPanResponderRelease: (_, gs) => {
|
translateX.setValue(x);
|
||||||
const x = Math.max(0, Math.min(gs.dx, maxX));
|
};
|
||||||
if (maxX > 0 && x / maxX >= THRESHOLD) {
|
|
||||||
// Snap to end + confirm
|
const onTouchEnd = () => {
|
||||||
Animated.timing(pan, { toValue: maxX, duration: 120, useNativeDriver: false }).start(() => {
|
if (disabled || done) return;
|
||||||
|
if (maxX > 0 && currentX.current / maxX >= THRESHOLD) {
|
||||||
|
Animated.timing(translateX, { toValue: maxX, duration: 100, useNativeDriver: true }).start(() => {
|
||||||
setDone(true);
|
setDone(true);
|
||||||
onConfirm();
|
onConfirm();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Snap back
|
Animated.spring(translateX, { toValue: 0, useNativeDriver: true, speed: 25, bounciness: 4 }).start();
|
||||||
Animated.spring(pan, { toValue: 0, useNativeDriver: false, speed: 20 }).start();
|
currentX.current = 0;
|
||||||
}
|
}
|
||||||
},
|
};
|
||||||
})
|
|
||||||
).current;
|
|
||||||
|
|
||||||
// Interpolate opacity of the label as handle moves right
|
// Fade label as handle moves right
|
||||||
// Guard: inputRange must be monotonically non-decreasing (maxX could be 0 before layout)
|
|
||||||
const safeMax = Math.max(maxX * 0.5, 1);
|
const safeMax = Math.max(maxX * 0.5, 1);
|
||||||
const labelOpacity = pan.interpolate({
|
const labelOpacity = translateX.interpolate({
|
||||||
inputRange: [0, safeMax],
|
inputRange: [0, safeMax],
|
||||||
outputRange: [1, 0],
|
outputRange: [1, 0],
|
||||||
extrapolate: 'clamp',
|
extrapolate: 'clamp',
|
||||||
@@ -58,23 +59,31 @@ export function SlideToConfirm({ label = 'Slide to confirm', color = '#059669',
|
|||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
onLayout={e => setTrackW(e.nativeEvent.layout.width)}
|
onLayout={e => setTrackW(e.nativeEvent.layout.width)}
|
||||||
style={[styles.track, { backgroundColor: done ? color : '#F1F5F9', borderColor: done ? color : '#E2E8F0' }]}
|
style={[styles.track, {
|
||||||
|
backgroundColor: done ? color : '#F1F5F9',
|
||||||
|
borderColor: done ? color : '#E2E8F0',
|
||||||
|
}]}
|
||||||
>
|
>
|
||||||
{/* Label */}
|
{/* Label */}
|
||||||
<Animated.Text style={[styles.label, { opacity: disabled ? 0.4 : labelOpacity, color: done ? '#fff' : '#64748B' }]}>
|
<Animated.Text style={[styles.label, {
|
||||||
|
opacity: disabled ? 0.35 : labelOpacity,
|
||||||
|
color: done ? '#fff' : '#64748B',
|
||||||
|
}]}>
|
||||||
{done ? '✓ Confirmed!' : label}
|
{done ? '✓ Confirmed!' : label}
|
||||||
</Animated.Text>
|
</Animated.Text>
|
||||||
|
|
||||||
{/* Handle */}
|
{/* Handle — touch events here */}
|
||||||
{!done && (
|
{!done && (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
{...(disabled ? {} : panResponder.panHandlers)}
|
onTouchStart={onTouchStart}
|
||||||
style={[
|
onTouchMove={onTouchMove}
|
||||||
styles.handle,
|
onTouchEnd={onTouchEnd}
|
||||||
{ backgroundColor: disabled ? '#CBD5E1' : color, left: PADDING, transform: [{ translateX: pan }] },
|
style={[styles.handle, {
|
||||||
]}
|
backgroundColor: disabled ? '#CBD5E1' : color,
|
||||||
|
transform: [{ translateX }],
|
||||||
|
}]}
|
||||||
>
|
>
|
||||||
<Text style={{ color: '#fff', fontSize: 20, fontWeight: '700' }}>{'›'}</Text>
|
<Text style={{ color: '#fff', fontSize: 22, fontWeight: '700', marginLeft: 2 }}>›</Text>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -89,25 +98,28 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
position: 'relative',
|
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
letterSpacing: 0.3,
|
letterSpacing: 0.3,
|
||||||
|
textAlign: 'center',
|
||||||
|
paddingLeft: HANDLE_SIZE + PADDING * 2,
|
||||||
|
paddingRight: 16,
|
||||||
},
|
},
|
||||||
handle: {
|
handle: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
left: PADDING,
|
||||||
top: PADDING,
|
top: PADDING,
|
||||||
width: HANDLE_SIZE,
|
width: HANDLE_SIZE,
|
||||||
height: HANDLE_SIZE,
|
height: HANDLE_SIZE,
|
||||||
borderRadius: HANDLE_SIZE / 2,
|
borderRadius: HANDLE_SIZE / 2,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
elevation: 3,
|
elevation: 4,
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
shadowOffset: { width: 0, height: 2 },
|
shadowOffset: { width: 0, height: 2 },
|
||||||
shadowOpacity: 0.15,
|
shadowOpacity: 0.2,
|
||||||
shadowRadius: 4,
|
shadowRadius: 4,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user