348 lines
9.2 KiB
TypeScript
348 lines
9.2 KiB
TypeScript
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<number, string>;
|
|
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 (
|
|
<View style={[styles.container, style, { height: containerHeight }]}>
|
|
<View
|
|
style={[
|
|
styles.track,
|
|
{
|
|
height: trackHeight,
|
|
backgroundColor: inactiveColor,
|
|
borderRadius: trackHeight / 2,
|
|
},
|
|
]}
|
|
onLayout={onLayout}
|
|
>
|
|
<Animated.View
|
|
style={[
|
|
styles.activeTrack,
|
|
trackStyle,
|
|
{
|
|
height: trackHeight,
|
|
backgroundColor: activeColor,
|
|
borderRadius: trackHeight / 2,
|
|
},
|
|
]}
|
|
/>
|
|
</View>
|
|
|
|
{/* Thumb 1 - Only render if range is true */}
|
|
{range && (
|
|
<GestureDetector gesture={thumb1Gesture}>
|
|
<Animated.View
|
|
style={[
|
|
styles.thumb,
|
|
thumb1Style,
|
|
{
|
|
width: thumbSize,
|
|
height: thumbSize,
|
|
borderRadius: thumbSize / 2,
|
|
backgroundColor: "white",
|
|
borderColor: activeColor,
|
|
borderWidth: 2,
|
|
},
|
|
disabled && styles.disabledThumb,
|
|
]}
|
|
/>
|
|
</GestureDetector>
|
|
)}
|
|
|
|
{/* Thumb 2 - Always render */}
|
|
<GestureDetector gesture={thumb2Gesture}>
|
|
<Animated.View
|
|
style={[
|
|
styles.thumb,
|
|
thumb2Style,
|
|
{
|
|
width: thumbSize,
|
|
height: thumbSize,
|
|
borderRadius: thumbSize / 2,
|
|
backgroundColor: "white",
|
|
borderColor: activeColor,
|
|
borderWidth: 2,
|
|
},
|
|
disabled && styles.disabledThumb,
|
|
]}
|
|
/>
|
|
</GestureDetector>
|
|
|
|
{/* Marks */}
|
|
{marks && (
|
|
<View
|
|
style={{
|
|
position: "absolute",
|
|
top: marksTop,
|
|
width: "100%",
|
|
height: 20,
|
|
}}
|
|
>
|
|
{Object.entries(marks).map(([key, label]) => {
|
|
const val = Number(key);
|
|
const pos = getPositionFromValue(val);
|
|
const leftPos = Math.max(0, Math.min(pos - 10, width - 40));
|
|
return (
|
|
<Text
|
|
key={key}
|
|
style={{
|
|
position: "absolute",
|
|
left: leftPos,
|
|
fontSize: 12,
|
|
color: "#666",
|
|
textAlign: "center",
|
|
width: "auto",
|
|
marginTop: 5,
|
|
}}
|
|
>
|
|
{label}
|
|
</Text>
|
|
);
|
|
})}
|
|
</View>
|
|
)}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
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",
|
|
},
|
|
});
|