fix: SlideToConfirm - RNGH GestureHandlerRootView + Reanimated worklets (proper Modal iOS fix)
This commit is contained in:
@@ -1,125 +1,123 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { View, Text, Animated, StyleSheet } from 'react-native';
|
||||
import { useCallback } from 'react';
|
||||
import { StyleSheet, Text, View, ViewStyle } from 'react-native';
|
||||
import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import Animated, { runOnJS, useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated';
|
||||
|
||||
interface Props {
|
||||
onConfirm: () => void;
|
||||
label?: string;
|
||||
color?: string;
|
||||
threshold?: number;
|
||||
disabled?: boolean;
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
const TRACK_HEIGHT = 60;
|
||||
const HANDLE_SIZE = 48;
|
||||
const PADDING = 6;
|
||||
const THRESHOLD = 0.82;
|
||||
const TRACK_PADDING = 5;
|
||||
|
||||
interface Props {
|
||||
label?: string;
|
||||
color?: string;
|
||||
onConfirm: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
export function SlideToConfirm({
|
||||
onConfirm,
|
||||
label = 'Slide to confirm',
|
||||
color = '#059669',
|
||||
threshold = 0.82,
|
||||
disabled = false,
|
||||
style,
|
||||
}: Props) {
|
||||
const translateX = useSharedValue(0);
|
||||
const maxX = useSharedValue(200); // updated on layout
|
||||
|
||||
export function SlideToConfirm({ label = 'Slide to confirm', color = '#059669', onConfirm, disabled = false }: Props) {
|
||||
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 triggerConfirm = useCallback(() => { onConfirm(); }, [onConfirm]);
|
||||
|
||||
const maxX = Math.max(trackW - HANDLE_SIZE - PADDING * 2, 1);
|
||||
|
||||
const onTouchStart = (e: any) => {
|
||||
if (disabled || done) return;
|
||||
startX.current = e.nativeEvent.pageX;
|
||||
};
|
||||
|
||||
const onTouchMove = (e: any) => {
|
||||
if (disabled || done) return;
|
||||
const dx = e.nativeEvent.pageX - startX.current;
|
||||
const x = Math.max(0, Math.min(dx, maxX));
|
||||
currentX.current = x;
|
||||
translateX.setValue(x);
|
||||
};
|
||||
|
||||
const onTouchEnd = () => {
|
||||
if (disabled || done) return;
|
||||
if (maxX > 0 && currentX.current / maxX >= THRESHOLD) {
|
||||
Animated.timing(translateX, { toValue: maxX, duration: 100, useNativeDriver: false }).start(() => {
|
||||
setDone(true);
|
||||
onConfirm();
|
||||
});
|
||||
const pan = Gesture.Pan()
|
||||
.enabled(!disabled)
|
||||
.minDistance(0)
|
||||
.activeOffsetX([-5, 5])
|
||||
.onUpdate((e) => {
|
||||
'worklet';
|
||||
translateX.value = Math.max(0, Math.min(e.translationX, maxX.value));
|
||||
})
|
||||
.onEnd(() => {
|
||||
'worklet';
|
||||
if (translateX.value >= maxX.value * threshold) {
|
||||
translateX.value = withSpring(maxX.value, { damping: 20, stiffness: 200, mass: 0.8 });
|
||||
runOnJS(triggerConfirm)();
|
||||
} else {
|
||||
Animated.spring(translateX, { toValue: 0, useNativeDriver: false, speed: 25, bounciness: 4 }).start();
|
||||
currentX.current = 0;
|
||||
translateX.value = withSpring(0, { damping: 20, stiffness: 200, mass: 0.8 });
|
||||
}
|
||||
};
|
||||
|
||||
// Fade label as handle moves right
|
||||
const safeMax = Math.max(maxX * 0.5, 1);
|
||||
const labelOpacity = translateX.interpolate({
|
||||
inputRange: [0, safeMax],
|
||||
outputRange: [1, 0],
|
||||
extrapolate: 'clamp',
|
||||
});
|
||||
|
||||
const animatedHandle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateX: translateX.value }],
|
||||
}));
|
||||
|
||||
const animatedFill = useAnimatedStyle(() => ({
|
||||
width: translateX.value + HANDLE_SIZE + TRACK_PADDING,
|
||||
}));
|
||||
|
||||
const animatedLabel = useAnimatedStyle(() => ({
|
||||
opacity: Math.max(0, 1 - (translateX.value / Math.max(maxX.value * 0.5, 1))),
|
||||
}));
|
||||
|
||||
return (
|
||||
// GestureHandlerRootView inside Modal is REQUIRED on iOS New Arch
|
||||
// Modal creates a new UIWindow — RNGH's gesture manager must be re-rooted here
|
||||
<GestureHandlerRootView style={[styles.root, style]}>
|
||||
<View
|
||||
onLayout={e => setTrackW(e.nativeEvent.layout.width)}
|
||||
style={[styles.track, {
|
||||
backgroundColor: done ? color : '#F1F5F9',
|
||||
borderColor: done ? color : '#E2E8F0',
|
||||
}]}
|
||||
style={[styles.track, { borderColor: color + '50' }]}
|
||||
onLayout={(e) => {
|
||||
const w = e.nativeEvent.layout.width;
|
||||
maxX.value = w - HANDLE_SIZE - TRACK_PADDING * 2;
|
||||
}}
|
||||
>
|
||||
{/* Label */}
|
||||
<Animated.Text style={[styles.label, {
|
||||
opacity: disabled ? 0.35 : labelOpacity,
|
||||
color: done ? '#fff' : '#64748B',
|
||||
}]}>
|
||||
{done ? '✓ Confirmed!' : label}
|
||||
{/* Colored fill that grows behind the handle */}
|
||||
<Animated.View style={[styles.fill, { backgroundColor: color + '28' }, animatedFill]} />
|
||||
|
||||
{/* Label fades as handle moves right */}
|
||||
<Animated.Text
|
||||
style={[styles.label, { paddingLeft: HANDLE_SIZE + TRACK_PADDING * 3 }, animatedLabel]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{label}
|
||||
</Animated.Text>
|
||||
|
||||
{/* Handle — touch events here */}
|
||||
{!done && (
|
||||
<Animated.View
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
style={[styles.handle, {
|
||||
backgroundColor: disabled ? '#CBD5E1' : color,
|
||||
transform: [{ translateX }],
|
||||
}]}
|
||||
>
|
||||
<Text style={{ color: '#fff', fontSize: 22, fontWeight: '700', marginLeft: 2 }}>›</Text>
|
||||
{/* Draggable handle */}
|
||||
<GestureDetector gesture={pan}>
|
||||
<Animated.View style={[
|
||||
styles.handle,
|
||||
{ backgroundColor: disabled ? '#CBD5E1' : color, width: HANDLE_SIZE, height: HANDLE_SIZE, borderRadius: HANDLE_SIZE / 2, top: (TRACK_HEIGHT - HANDLE_SIZE) / 2, left: TRACK_PADDING },
|
||||
animatedHandle,
|
||||
]}>
|
||||
<Text style={styles.arrow}>›</Text>
|
||||
</Animated.View>
|
||||
)}
|
||||
</GestureDetector>
|
||||
</View>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: { width: '100%' },
|
||||
track: {
|
||||
height: TRACK_HEIGHT,
|
||||
borderRadius: TRACK_HEIGHT / 2,
|
||||
borderWidth: 1.5,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
overflow: 'hidden',
|
||||
height: TRACK_HEIGHT, borderRadius: TRACK_HEIGHT / 2,
|
||||
backgroundColor: '#F1F5F9', borderWidth: 1.5,
|
||||
justifyContent: 'center', alignItems: 'center',
|
||||
overflow: 'hidden', position: 'relative',
|
||||
},
|
||||
fill: {
|
||||
position: 'absolute', left: 0, top: 0,
|
||||
height: TRACK_HEIGHT, zIndex: 1,
|
||||
},
|
||||
label: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.3,
|
||||
textAlign: 'center',
|
||||
paddingLeft: HANDLE_SIZE + PADDING * 2,
|
||||
paddingRight: 16,
|
||||
position: 'absolute', zIndex: 2,
|
||||
fontSize: 14, fontWeight: '700', color: '#64748B',
|
||||
textAlign: 'center', width: '100%', paddingRight: 16,
|
||||
},
|
||||
handle: {
|
||||
position: 'absolute',
|
||||
left: PADDING,
|
||||
top: PADDING,
|
||||
width: HANDLE_SIZE,
|
||||
height: HANDLE_SIZE,
|
||||
borderRadius: HANDLE_SIZE / 2,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
elevation: 4,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 4,
|
||||
position: 'absolute', zIndex: 3,
|
||||
justifyContent: 'center', alignItems: 'center',
|
||||
shadowColor: '#000', shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.18, shadowRadius: 4, elevation: 4,
|
||||
},
|
||||
arrow: { fontSize: 26, color: '#fff', fontWeight: '800', marginLeft: 2 },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user