fix: rewrite SlideToConfirm with Reanimated only (no RNGH); remove GestureHandlerRootView
This commit is contained in:
@@ -2,7 +2,6 @@ import '../global.css';
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Stack, router } from 'expo-router';
|
import { Stack, router } from 'expo-router';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../stores/authStore';
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
@@ -25,10 +24,8 @@ export default function RootLayout() {
|
|||||||
}, [isLoading, token]);
|
}, [isLoading, token]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<Stack screenOptions={{ headerShown: false }} />
|
<Stack screenOptions={{ headerShown: false }} />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</GestureHandlerRootView>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,123 +1,134 @@
|
|||||||
import { useCallback } from 'react';
|
/**
|
||||||
|
* SlideToConfirm — No RNGH dependency.
|
||||||
|
* Uses Reanimated useSharedValue (JSI-direct, sets on UI thread immediately)
|
||||||
|
* + onTouchStart/Move/End on the handle for gesture capture.
|
||||||
|
*/
|
||||||
|
import { useRef } from 'react';
|
||||||
import { StyleSheet, Text, View, ViewStyle } from 'react-native';
|
import { StyleSheet, Text, View, ViewStyle } from 'react-native';
|
||||||
import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler';
|
import Animated, {
|
||||||
import Animated, { runOnJS, useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated';
|
runOnJS,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withSpring,
|
||||||
|
} from 'react-native-reanimated';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
label?: string;
|
label?: string;
|
||||||
color?: string;
|
|
||||||
threshold?: number;
|
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
style?: ViewStyle;
|
style?: ViewStyle;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TRACK_HEIGHT = 60;
|
const HANDLE_SIZE = 56;
|
||||||
const HANDLE_SIZE = 48;
|
const THRESHOLD = 0.80; // 80% of track
|
||||||
const TRACK_PADDING = 5;
|
|
||||||
|
|
||||||
export function SlideToConfirm({
|
export function SlideToConfirm({ onConfirm, label = 'Slide to confirm', disabled = false, style }: Props) {
|
||||||
onConfirm,
|
const trackWidth = useRef(0);
|
||||||
label = 'Slide to confirm',
|
const startX = useRef(0);
|
||||||
color = '#059669',
|
const confirmed = useRef(false);
|
||||||
threshold = 0.82,
|
|
||||||
disabled = false,
|
|
||||||
style,
|
|
||||||
}: Props) {
|
|
||||||
const translateX = useSharedValue(0);
|
const translateX = useSharedValue(0);
|
||||||
const maxX = useSharedValue(200); // updated on layout
|
|
||||||
|
|
||||||
const triggerConfirm = useCallback(() => { onConfirm(); }, [onConfirm]);
|
const handleStyle = useAnimatedStyle(() => ({
|
||||||
|
|
||||||
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 {
|
|
||||||
translateX.value = withSpring(0, { damping: 20, stiffness: 200, mass: 0.8 });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const animatedHandle = useAnimatedStyle(() => ({
|
|
||||||
transform: [{ translateX: translateX.value }],
|
transform: [{ translateX: translateX.value }],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const animatedFill = useAnimatedStyle(() => ({
|
const labelStyle = useAnimatedStyle(() => {
|
||||||
width: translateX.value + HANDLE_SIZE + TRACK_PADDING,
|
const maxX = Math.max(trackWidth.current - HANDLE_SIZE, 1);
|
||||||
}));
|
const ratio = translateX.value / maxX;
|
||||||
|
return { opacity: 1 - ratio * 1.5 };
|
||||||
|
});
|
||||||
|
|
||||||
const animatedLabel = useAnimatedStyle(() => ({
|
const onTouchStart = (e: any) => {
|
||||||
opacity: Math.max(0, 1 - (translateX.value / Math.max(maxX.value * 0.5, 1))),
|
if (disabled) return;
|
||||||
}));
|
confirmed.current = false;
|
||||||
|
startX.current = e.nativeEvent.pageX - translateX.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTouchMove = (e: any) => {
|
||||||
|
if (disabled) return;
|
||||||
|
const maxX = Math.max(trackWidth.current - HANDLE_SIZE, 1);
|
||||||
|
const raw = e.nativeEvent.pageX - startX.current;
|
||||||
|
const clamped = Math.min(Math.max(raw, 0), maxX);
|
||||||
|
translateX.value = clamped;
|
||||||
|
|
||||||
|
if (!confirmed.current && clamped / maxX >= THRESHOLD) {
|
||||||
|
confirmed.current = true;
|
||||||
|
translateX.value = withSpring(maxX, { damping: 18, stiffness: 220 });
|
||||||
|
runOnJS(onConfirm)();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTouchEnd = () => {
|
||||||
|
if (disabled || confirmed.current) return;
|
||||||
|
translateX.value = withSpring(0, { damping: 18, stiffness: 220 });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
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
|
<View
|
||||||
style={[styles.track, { borderColor: color + '50' }]}
|
style={[styles.track, disabled && styles.trackDisabled, style]}
|
||||||
onLayout={(e) => {
|
onLayout={e => { trackWidth.current = e.nativeEvent.layout.width; }}
|
||||||
const w = e.nativeEvent.layout.width;
|
|
||||||
maxX.value = w - HANDLE_SIZE - TRACK_PADDING * 2;
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* Colored fill that grows behind the handle */}
|
|
||||||
<Animated.View style={[styles.fill, { backgroundColor: color + '28' }, animatedFill]} />
|
|
||||||
|
|
||||||
{/* Label fades as handle moves right */}
|
{/* Label fades as handle moves right */}
|
||||||
<Animated.Text
|
<Animated.View style={[StyleSheet.absoluteFill, styles.labelWrap, labelStyle]} pointerEvents="none">
|
||||||
style={[styles.label, { paddingLeft: HANDLE_SIZE + TRACK_PADDING * 3 }, animatedLabel]}
|
<Text style={styles.label}>{label}</Text>
|
||||||
numberOfLines={1}
|
</Animated.View>
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Animated.Text>
|
|
||||||
|
|
||||||
{/* Draggable handle */}
|
{/* Sliding handle */}
|
||||||
<GestureDetector gesture={pan}>
|
<Animated.View
|
||||||
<Animated.View style={[
|
style={[styles.handle, disabled && styles.handleDisabled, handleStyle]}
|
||||||
styles.handle,
|
onTouchStart={onTouchStart}
|
||||||
{ backgroundColor: disabled ? '#CBD5E1' : color, width: HANDLE_SIZE, height: HANDLE_SIZE, borderRadius: HANDLE_SIZE / 2, top: (TRACK_HEIGHT - HANDLE_SIZE) / 2, left: TRACK_PADDING },
|
onTouchMove={onTouchMove}
|
||||||
animatedHandle,
|
onTouchEnd={onTouchEnd}
|
||||||
]}>
|
onTouchCancel={onTouchEnd}
|
||||||
|
>
|
||||||
<Text style={styles.arrow}>›</Text>
|
<Text style={styles.arrow}>›</Text>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</GestureDetector>
|
|
||||||
</View>
|
</View>
|
||||||
</GestureHandlerRootView>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
root: { width: '100%' },
|
|
||||||
track: {
|
track: {
|
||||||
height: TRACK_HEIGHT, borderRadius: TRACK_HEIGHT / 2,
|
height: 60,
|
||||||
backgroundColor: '#F1F5F9', borderWidth: 1.5,
|
backgroundColor: '#0E7490',
|
||||||
justifyContent: 'center', alignItems: 'center',
|
borderRadius: 30,
|
||||||
overflow: 'hidden', position: 'relative',
|
justifyContent: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
fill: {
|
trackDisabled: {
|
||||||
position: 'absolute', left: 0, top: 0,
|
backgroundColor: '#94A3B8',
|
||||||
height: TRACK_HEIGHT, zIndex: 1,
|
},
|
||||||
|
labelWrap: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
position: 'absolute', zIndex: 2,
|
color: 'rgba(255,255,255,0.85)',
|
||||||
fontSize: 14, fontWeight: '700', color: '#64748B',
|
fontSize: 15,
|
||||||
textAlign: 'center', width: '100%', paddingRight: 16,
|
fontWeight: '600',
|
||||||
|
letterSpacing: 0.3,
|
||||||
},
|
},
|
||||||
handle: {
|
handle: {
|
||||||
position: 'absolute', zIndex: 3,
|
width: HANDLE_SIZE,
|
||||||
justifyContent: 'center', alignItems: 'center',
|
height: HANDLE_SIZE,
|
||||||
shadowColor: '#000', shadowOffset: { width: 0, height: 2 },
|
borderRadius: HANDLE_SIZE / 2,
|
||||||
shadowOpacity: 0.18, shadowRadius: 4, elevation: 4,
|
backgroundColor: '#fff',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginLeft: 3,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.18,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 4,
|
||||||
|
},
|
||||||
|
handleDisabled: {
|
||||||
|
backgroundColor: '#E2E8F0',
|
||||||
|
},
|
||||||
|
arrow: {
|
||||||
|
fontSize: 28,
|
||||||
|
color: '#0891B2',
|
||||||
|
fontWeight: '800',
|
||||||
|
lineHeight: 32,
|
||||||
},
|
},
|
||||||
arrow: { fontSize: 26, color: '#fff', fontWeight: '800', marginLeft: 2 },
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user