hiển thị thuyền thông tin tàu
This commit is contained in:
346
components/Slider.tsx
Normal file
346
components/Slider.tsx
Normal file
@@ -0,0 +1,346 @@
|
||||
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",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user