Files
sgw-owner-app/components/Slider.tsx
2025-12-03 16:22:25 +07:00

347 lines
9.1 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);
return (
<Text
key={key}
style={{
position: "absolute",
left: pos - 10,
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",
},
});