126 lines
3.4 KiB
TypeScript
126 lines
3.4 KiB
TypeScript
import { useRef, useState } from 'react';
|
||
import { View, Text, Animated, StyleSheet } from 'react-native';
|
||
|
||
const TRACK_HEIGHT = 60;
|
||
const HANDLE_SIZE = 48;
|
||
const PADDING = 6;
|
||
const THRESHOLD = 0.82;
|
||
|
||
interface Props {
|
||
label?: string;
|
||
color?: string;
|
||
onConfirm: () => void;
|
||
disabled?: boolean;
|
||
}
|
||
|
||
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 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: true }).start(() => {
|
||
setDone(true);
|
||
onConfirm();
|
||
});
|
||
} else {
|
||
Animated.spring(translateX, { toValue: 0, useNativeDriver: true, speed: 25, bounciness: 4 }).start();
|
||
currentX.current = 0;
|
||
}
|
||
};
|
||
|
||
// 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',
|
||
});
|
||
|
||
return (
|
||
<View
|
||
onLayout={e => setTrackW(e.nativeEvent.layout.width)}
|
||
style={[styles.track, {
|
||
backgroundColor: done ? color : '#F1F5F9',
|
||
borderColor: done ? color : '#E2E8F0',
|
||
}]}
|
||
>
|
||
{/* Label */}
|
||
<Animated.Text style={[styles.label, {
|
||
opacity: disabled ? 0.35 : labelOpacity,
|
||
color: done ? '#fff' : '#64748B',
|
||
}]}>
|
||
{done ? '✓ Confirmed!' : 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>
|
||
</Animated.View>
|
||
)}
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
track: {
|
||
height: TRACK_HEIGHT,
|
||
borderRadius: TRACK_HEIGHT / 2,
|
||
borderWidth: 1.5,
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
overflow: 'hidden',
|
||
},
|
||
label: {
|
||
fontSize: 15,
|
||
fontWeight: '700',
|
||
letterSpacing: 0.3,
|
||
textAlign: 'center',
|
||
paddingLeft: HANDLE_SIZE + PADDING * 2,
|
||
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,
|
||
},
|
||
});
|