import { useEffect, useState } from "react"; import { LayoutChangeEvent, StyleSheet, Text, View } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import Animated, { runOnJS, useAnimatedStyle, useSharedValue, withSpring, withTiming, } from "react-native-reanimated"; interface SliderProps { min?: number; max?: number; step?: number; range?: boolean; value?: number | [number, number]; onValueChange?: (value: any) => void; onSlidingComplete?: (value: any) => void; disabled?: boolean; trackHeight?: number; thumbSize?: number; activeColor?: string; inactiveColor?: string; marks?: Record; style?: any; } export default function Slider({ min = 0, max = 100, step = 1, range = false, value = 0, onValueChange, onSlidingComplete, disabled = false, trackHeight = 4, thumbSize = 20, activeColor = "#1677ff", // Ant Design blue inactiveColor = "#e5e7eb", // Gray-200 marks, style, }: SliderProps) { const [width, setWidth] = useState(0); // Shared values for positions const translateX1 = useSharedValue(0); // Left thumb (or 0 for single) const translateX2 = useSharedValue(0); // Right thumb (or value for single) const isDragging1 = useSharedValue(false); const isDragging2 = useSharedValue(false); const scale1 = useSharedValue(1); const scale2 = useSharedValue(1); const context1 = useSharedValue(0); const context2 = useSharedValue(0); // Calculate position from value const getPositionFromValue = (val: number) => { "worklet"; if (width === 0) return 0; const clampedVal = Math.min(Math.max(val, min), max); return ((clampedVal - min) / (max - min)) * width; }; // Calculate value from position const getValueFromPosition = (pos: number) => { "worklet"; if (width === 0) return min; const percentage = Math.min(Math.max(pos, 0), width) / width; const rawValue = min + percentage * (max - min); // Snap to step const steppedValue = Math.round(rawValue / step) * step; return Math.min(Math.max(steppedValue, min), max); }; // Update positions when props change useEffect(() => { if (width === 0) return; if (range) { const vals = Array.isArray(value) ? value : [min, value as number]; const v1 = Math.min(vals[0], vals[1]); const v2 = Math.max(vals[0], vals[1]); if (!isDragging1.value) { translateX1.value = withTiming(getPositionFromValue(v1), { duration: 200, }); } if (!isDragging2.value) { translateX2.value = withTiming(getPositionFromValue(v2), { duration: 200, }); } } else { const val = typeof value === "number" ? value : value[0]; if (!isDragging2.value) { translateX2.value = withTiming(getPositionFromValue(val), { duration: 200, }); } translateX1.value = 0; // Always 0 for single slider track start } }, [value, width, min, max, range]); // Thumb 1 Gesture (Only for range) const thumb1Gesture = Gesture.Pan() .enabled(!disabled && range) .onStart(() => { context1.value = translateX1.value; isDragging1.value = true; scale1.value = withSpring(1.2); }) .onUpdate((event) => { if (width === 0) return; let newPos = context1.value + event.translationX; // Constrain: 0 <= newPos <= translateX2 const maxPos = translateX2.value; newPos = Math.min(Math.max(newPos, 0), maxPos); translateX1.value = newPos; if (onValueChange) { const v1 = getValueFromPosition(newPos); const v2 = getValueFromPosition(translateX2.value); runOnJS(onValueChange)([v1, v2]); } }) .onEnd(() => { isDragging1.value = false; scale1.value = withSpring(1); if (onSlidingComplete) { const v1 = getValueFromPosition(translateX1.value); const v2 = getValueFromPosition(translateX2.value); runOnJS(onSlidingComplete)([v1, v2]); } }); // Thumb 2 Gesture (Main thumb for single, Right thumb for range) const thumb2Gesture = Gesture.Pan() .enabled(!disabled) .onStart(() => { context2.value = translateX2.value; isDragging2.value = true; scale2.value = withSpring(1.2); }) .onUpdate((event) => { if (width === 0) return; let newPos = context2.value + event.translationX; // Constrain: translateX1 <= newPos <= width const minPos = range ? translateX1.value : 0; newPos = Math.min(Math.max(newPos, minPos), width); translateX2.value = newPos; if (onValueChange) { const v2 = getValueFromPosition(newPos); if (range) { const v1 = getValueFromPosition(translateX1.value); runOnJS(onValueChange)([v1, v2]); } else { runOnJS(onValueChange)(v2); } } }) .onEnd(() => { isDragging2.value = false; scale2.value = withSpring(1); if (onSlidingComplete) { const v2 = getValueFromPosition(translateX2.value); if (range) { const v1 = getValueFromPosition(translateX1.value); runOnJS(onSlidingComplete)([v1, v2]); } else { runOnJS(onSlidingComplete)(v2); } } }); const trackStyle = useAnimatedStyle(() => { return { left: range ? translateX1.value : 0, width: range ? translateX2.value - translateX1.value : translateX2.value, }; }); const thumb1Style = useAnimatedStyle(() => { return { transform: [ { translateX: translateX1.value - thumbSize / 2 }, { scale: scale1.value }, ], zIndex: isDragging1.value ? 10 : 1, }; }); const thumb2Style = useAnimatedStyle(() => { return { transform: [ { translateX: translateX2.value - thumbSize / 2 }, { scale: scale2.value }, ], zIndex: isDragging2.value ? 10 : 1, }; }); const onLayout = (event: LayoutChangeEvent) => { setWidth(event.nativeEvent.layout.width); }; const containerHeight = Math.max(trackHeight, thumbSize) + (marks ? 30 : 0); const marksTop = Math.max(trackHeight, thumbSize) + 10; return ( {/* Thumb 1 - Only render if range is true */} {range && ( )} {/* Thumb 2 - Always render */} {/* Marks */} {marks && ( {Object.entries(marks).map(([key, label]) => { const val = Number(key); const pos = getPositionFromValue(val); return ( {label} ); })} )} ); } const styles = StyleSheet.create({ container: { justifyContent: "center", width: "100%", }, track: { width: "100%", position: "absolute", overflow: "hidden", }, activeTrack: { position: "absolute", left: 0, top: 0, }, thumb: { position: "absolute", left: 0, shadowColor: "#000", shadowOffset: { width: 0, height: 2, }, shadowOpacity: 0.25, shadowRadius: 3.84, elevation: 5, }, disabledThumb: { borderColor: "#ccc", }, });