135 lines
3.5 KiB
TypeScript
135 lines
3.5 KiB
TypeScript
/**
|
||
* 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 Animated, {
|
||
runOnJS,
|
||
useAnimatedStyle,
|
||
useSharedValue,
|
||
withSpring,
|
||
} from 'react-native-reanimated';
|
||
|
||
interface Props {
|
||
onConfirm: () => void;
|
||
label?: string;
|
||
disabled?: boolean;
|
||
style?: ViewStyle;
|
||
}
|
||
|
||
const HANDLE_SIZE = 56;
|
||
const THRESHOLD = 0.80; // 80% of track
|
||
|
||
export function SlideToConfirm({ onConfirm, label = 'Slide to confirm', disabled = false, style }: Props) {
|
||
const trackWidth = useRef(0);
|
||
const startX = useRef(0);
|
||
const confirmed = useRef(false);
|
||
const translateX = useSharedValue(0);
|
||
|
||
const handleStyle = useAnimatedStyle(() => ({
|
||
transform: [{ translateX: translateX.value }],
|
||
}));
|
||
|
||
const labelStyle = useAnimatedStyle(() => {
|
||
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;
|
||
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 (
|
||
<View
|
||
style={[styles.track, disabled && styles.trackDisabled, style]}
|
||
onLayout={e => { trackWidth.current = e.nativeEvent.layout.width; }}
|
||
>
|
||
{/* Label fades as handle moves right */}
|
||
<Animated.View style={[StyleSheet.absoluteFill, styles.labelWrap, labelStyle]} pointerEvents="none">
|
||
<Text style={styles.label}>{label}</Text>
|
||
</Animated.View>
|
||
|
||
{/* Sliding handle */}
|
||
<Animated.View
|
||
style={[styles.handle, disabled && styles.handleDisabled, handleStyle]}
|
||
onTouchStart={onTouchStart}
|
||
onTouchMove={onTouchMove}
|
||
onTouchEnd={onTouchEnd}
|
||
onTouchCancel={onTouchEnd}
|
||
>
|
||
<Text style={styles.arrow}>›</Text>
|
||
</Animated.View>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
track: {
|
||
height: 60,
|
||
backgroundColor: '#0E7490',
|
||
borderRadius: 30,
|
||
justifyContent: 'center',
|
||
overflow: 'hidden',
|
||
},
|
||
trackDisabled: {
|
||
backgroundColor: '#94A3B8',
|
||
},
|
||
labelWrap: {
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
label: {
|
||
color: 'rgba(255,255,255,0.85)',
|
||
fontSize: 15,
|
||
fontWeight: '600',
|
||
letterSpacing: 0.3,
|
||
},
|
||
handle: {
|
||
width: HANDLE_SIZE,
|
||
height: HANDLE_SIZE,
|
||
borderRadius: HANDLE_SIZE / 2,
|
||
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,
|
||
},
|
||
});
|