fix: SlideToConfirm pure PanResponder+Animated - no Reanimated/RNGH (Expo Go compat)
This commit is contained in:
@@ -1,16 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* SlideToConfirm — No RNGH dependency.
|
* SlideToConfirm — pure RN, no RNGH, no Reanimated.
|
||||||
* Uses Reanimated useSharedValue (JSI-direct, sets on UI thread immediately)
|
* PanResponder on handle + Animated(useNativeDriver:false).
|
||||||
* + onTouchStart/Move/End on the handle for gesture capture.
|
|
||||||
*/
|
*/
|
||||||
import { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
import { StyleSheet, Text, View, ViewStyle } from 'react-native';
|
import {
|
||||||
import Animated, {
|
Animated,
|
||||||
runOnJS,
|
PanResponder,
|
||||||
useAnimatedStyle,
|
StyleSheet,
|
||||||
useSharedValue,
|
Text,
|
||||||
withSpring,
|
View,
|
||||||
} from 'react-native-reanimated';
|
ViewStyle,
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
@@ -20,66 +20,82 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const HANDLE_SIZE = 56;
|
const HANDLE_SIZE = 56;
|
||||||
const THRESHOLD = 0.80; // 80% of track
|
const THRESHOLD = 0.80;
|
||||||
|
|
||||||
export function SlideToConfirm({ onConfirm, label = 'Slide to confirm', disabled = false, style }: Props) {
|
export function SlideToConfirm({ onConfirm, label = 'Slide to confirm', disabled = false, style }: Props) {
|
||||||
const trackWidth = useRef(0);
|
const trackWidth = useRef(0);
|
||||||
const startX = useRef(0);
|
|
||||||
const confirmed = useRef(false);
|
const confirmed = useRef(false);
|
||||||
const translateX = useSharedValue(0);
|
const translateX = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
const handleStyle = useAnimatedStyle(() => ({
|
const panResponder = useRef(
|
||||||
transform: [{ translateX: translateX.value }],
|
PanResponder.create({
|
||||||
}));
|
onStartShouldSetPanResponder: () => !disabled,
|
||||||
|
onMoveShouldSetPanResponder: () => !disabled,
|
||||||
const labelStyle = useAnimatedStyle(() => {
|
onPanResponderGrant: () => {
|
||||||
const maxX = Math.max(trackWidth.current - HANDLE_SIZE, 1);
|
|
||||||
const ratio = translateX.value / maxX;
|
|
||||||
return { opacity: 1 - ratio * 1.5 };
|
|
||||||
});
|
|
||||||
|
|
||||||
const onTouchStart = (e: any) => {
|
|
||||||
if (disabled) return;
|
|
||||||
confirmed.current = false;
|
confirmed.current = false;
|
||||||
startX.current = e.nativeEvent.pageX - translateX.value;
|
(translateX as any).setOffset((translateX as any)._value);
|
||||||
};
|
translateX.setValue(0);
|
||||||
|
},
|
||||||
const onTouchMove = (e: any) => {
|
onPanResponderMove: (_, gs) => {
|
||||||
if (disabled) return;
|
const maxX = Math.max(trackWidth.current - HANDLE_SIZE - 6, 1);
|
||||||
const maxX = Math.max(trackWidth.current - HANDLE_SIZE, 1);
|
const clamped = Math.min(Math.max(gs.dx, 0), maxX);
|
||||||
const raw = e.nativeEvent.pageX - startX.current;
|
translateX.setValue(clamped - (translateX as any)._offset);
|
||||||
const clamped = Math.min(Math.max(raw, 0), maxX);
|
|
||||||
translateX.value = clamped;
|
|
||||||
|
|
||||||
if (!confirmed.current && clamped / maxX >= THRESHOLD) {
|
if (!confirmed.current && clamped / maxX >= THRESHOLD) {
|
||||||
confirmed.current = true;
|
confirmed.current = true;
|
||||||
translateX.value = withSpring(maxX, { damping: 18, stiffness: 220 });
|
(translateX as any).flattenOffset();
|
||||||
runOnJS(onConfirm)();
|
Animated.spring(translateX, {
|
||||||
|
toValue: maxX,
|
||||||
|
useNativeDriver: false,
|
||||||
|
damping: 18,
|
||||||
|
stiffness: 220,
|
||||||
|
}).start();
|
||||||
|
onConfirm();
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
onPanResponderRelease: () => {
|
||||||
|
(translateX as any).flattenOffset();
|
||||||
|
if (!confirmed.current) {
|
||||||
|
Animated.spring(translateX, {
|
||||||
|
toValue: 0,
|
||||||
|
useNativeDriver: false,
|
||||||
|
damping: 18,
|
||||||
|
stiffness: 220,
|
||||||
|
}).start();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPanResponderTerminate: () => {
|
||||||
|
(translateX as any).flattenOffset();
|
||||||
|
if (!confirmed.current) {
|
||||||
|
Animated.spring(translateX, {
|
||||||
|
toValue: 0,
|
||||||
|
useNativeDriver: false,
|
||||||
|
}).start();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).current;
|
||||||
|
|
||||||
const onTouchEnd = () => {
|
const maxXForOpacity = Math.max(trackWidth.current - HANDLE_SIZE - 6, 1);
|
||||||
if (disabled || confirmed.current) return;
|
const labelOpacity = translateX.interpolate({
|
||||||
translateX.value = withSpring(0, { damping: 18, stiffness: 220 });
|
inputRange: [0, maxXForOpacity > 0 ? maxXForOpacity * 0.6 : 1],
|
||||||
};
|
outputRange: [1, 0],
|
||||||
|
extrapolate: 'clamp',
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[styles.track, disabled && styles.trackDisabled, style]}
|
style={[styles.track, disabled && styles.trackDisabled, style]}
|
||||||
onLayout={e => { trackWidth.current = e.nativeEvent.layout.width; }}
|
onLayout={e => { trackWidth.current = e.nativeEvent.layout.width; }}
|
||||||
>
|
>
|
||||||
{/* Label fades as handle moves right */}
|
<Animated.View style={[StyleSheet.absoluteFill, styles.labelWrap, { opacity: labelOpacity }]} pointerEvents="none">
|
||||||
<Animated.View style={[StyleSheet.absoluteFill, styles.labelWrap, labelStyle]} pointerEvents="none">
|
|
||||||
<Text style={styles.label}>{label}</Text>
|
<Text style={styles.label}>{label}</Text>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
{/* Sliding handle */}
|
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[styles.handle, disabled && styles.handleDisabled, handleStyle]}
|
style={[styles.handle, disabled && styles.handleDisabled, { transform: [{ translateX }] }]}
|
||||||
onTouchStart={onTouchStart}
|
{...panResponder.panHandlers}
|
||||||
onTouchMove={onTouchMove}
|
collapsable={false}
|
||||||
onTouchEnd={onTouchEnd}
|
|
||||||
onTouchCancel={onTouchEnd}
|
|
||||||
>
|
>
|
||||||
<Text style={styles.arrow}>›</Text>
|
<Text style={styles.arrow}>›</Text>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -95,13 +111,8 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
trackDisabled: {
|
trackDisabled: { backgroundColor: '#94A3B8' },
|
||||||
backgroundColor: '#94A3B8',
|
labelWrap: { alignItems: 'center', justifyContent: 'center' },
|
||||||
},
|
|
||||||
labelWrap: {
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
label: {
|
label: {
|
||||||
color: 'rgba(255,255,255,0.85)',
|
color: 'rgba(255,255,255,0.85)',
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
@@ -122,9 +133,7 @@ const styles = StyleSheet.create({
|
|||||||
shadowRadius: 4,
|
shadowRadius: 4,
|
||||||
elevation: 4,
|
elevation: 4,
|
||||||
},
|
},
|
||||||
handleDisabled: {
|
handleDisabled: { backgroundColor: '#E2E8F0' },
|
||||||
backgroundColor: '#E2E8F0',
|
|
||||||
},
|
|
||||||
arrow: {
|
arrow: {
|
||||||
fontSize: 28,
|
fontSize: 28,
|
||||||
color: '#0891B2',
|
color: '#0891B2',
|
||||||
|
|||||||
Reference in New Issue
Block a user