Files
SeaGateway-App/components/ui/slice-switch.tsx
2025-11-15 16:58:07 +07:00

247 lines
6.5 KiB
TypeScript

import { Ionicons } from "@expo/vector-icons";
import type { ComponentProps } from "react";
import { useRef, useState } from "react";
import {
Animated,
OpaqueColorValue,
Pressable,
StyleProp,
StyleSheet,
View,
ViewStyle,
} from "react-native";
const SIZE_PRESETS = {
sm: { width: 64, height: 32 },
md: { width: 80, height: 40 },
lg: { width: 96, height: 48 },
} as const;
type SwitchSize = keyof typeof SIZE_PRESETS;
const DEFAULT_TOGGLE_DURATION = 400;
// Default both backgrounds to a grey tone when not provided
const DEFAULT_INACTIVE_BG = "#D3DAD9";
const DEFAULT_ACTIVE_BG = "#D3DAD9";
const PRESSED_SCALE = 0.96;
const PRESS_FEEDBACK_DURATION = 120;
type IoniconName = ComponentProps<typeof Ionicons>["name"];
type RotateSwitchProps = {
size?: SwitchSize;
leftIcon?: IoniconName;
leftIconColor?: string | OpaqueColorValue | undefined;
rightIconColor?: string | OpaqueColorValue | undefined;
rightIcon?: IoniconName;
duration?: number;
activeBackgroundColor?: string;
inactiveBackgroundColor?: string;
inactiveOverlayColor?: string;
activeOverlayColor?: string;
style?: StyleProp<ViewStyle>;
onChange?: (value: boolean) => void;
};
const SliceSwitch = ({
size = "md",
leftIcon,
rightIcon,
duration,
activeBackgroundColor = DEFAULT_ACTIVE_BG,
inactiveBackgroundColor = DEFAULT_INACTIVE_BG,
leftIconColor = "#fff",
rightIconColor = "#fff",
inactiveOverlayColor = "#000",
activeOverlayColor = "#000",
style,
onChange,
}: RotateSwitchProps) => {
const { width: containerWidth, height: containerHeight } =
SIZE_PRESETS[size] ?? SIZE_PRESETS.md;
const animationDuration = Math.max(duration ?? DEFAULT_TOGGLE_DURATION, 0);
const [isOn, setIsOn] = useState(false);
const [bgOn, setBgOn] = useState(false);
const progress = useRef(new Animated.Value(0)).current;
const pressScale = useRef(new Animated.Value(1)).current;
const overlayTranslateX = useRef(new Animated.Value(0)).current;
const listenerIdRef = useRef<string | number | null>(null);
const handleToggle = () => {
const next = !isOn;
const targetValue = next ? 1 : 0;
const overlayTarget = next ? containerWidth / 2 : 0;
progress.stopAnimation();
overlayTranslateX.stopAnimation();
if (animationDuration <= 0) {
progress.setValue(targetValue);
overlayTranslateX.setValue(overlayTarget);
setIsOn(next);
setBgOn(next);
onChange?.(next);
return;
}
setIsOn(next);
Animated.parallel([
Animated.timing(progress, {
toValue: targetValue,
duration: animationDuration,
useNativeDriver: true,
}),
Animated.timing(overlayTranslateX, {
toValue: overlayTarget,
duration: animationDuration,
useNativeDriver: true,
}),
]).start(() => {
setBgOn(next);
onChange?.(next);
});
// Remove any previous listener
if (listenerIdRef.current != null) {
progress.removeListener(listenerIdRef.current as string);
listenerIdRef.current = null;
}
// Swap image & background exactly at 50% progress
let swapped = false;
listenerIdRef.current = progress.addListener(({ value }) => {
if (swapped) return;
if (next && value >= 0.5) {
swapped = true;
setBgOn(next);
if (listenerIdRef.current != null) {
progress.removeListener(listenerIdRef.current as string);
listenerIdRef.current = null;
}
}
if (!next && value <= 0.5) {
swapped = true;
setBgOn(next);
if (listenerIdRef.current != null) {
progress.removeListener(listenerIdRef.current as string);
listenerIdRef.current = null;
}
}
});
};
const handlePressIn = () => {
pressScale.stopAnimation();
Animated.timing(pressScale, {
toValue: PRESSED_SCALE,
duration: PRESS_FEEDBACK_DURATION,
useNativeDriver: true,
}).start();
};
const handlePressOut = () => {
pressScale.stopAnimation();
Animated.timing(pressScale, {
toValue: 1,
duration: PRESS_FEEDBACK_DURATION,
useNativeDriver: true,
}).start();
};
return (
<Pressable
onPress={handleToggle}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
accessibilityRole="switch"
accessibilityState={{ checked: isOn }}
style={[styles.pressable, style]}
>
<Animated.View
style={[
styles.shadowWrapper,
{
transform: [{ scale: pressScale }],
width: containerWidth,
height: containerHeight,
borderRadius: containerHeight / 2,
},
]}
>
<View
style={[
styles.container,
{
flexDirection: "row",
justifyContent: "space-between",
borderRadius: containerHeight / 2,
backgroundColor: bgOn
? activeBackgroundColor
: inactiveBackgroundColor,
},
]}
>
<Animated.View
style={{
position: "absolute",
width: containerWidth / 2,
height: containerHeight * 0.95,
top: containerHeight * 0.01,
left: 0,
borderRadius: containerHeight * 0.95 / 2,
zIndex: 10,
backgroundColor: bgOn ? activeOverlayColor : inactiveOverlayColor,
transform: [{ translateX: overlayTranslateX }],
}}
/>
<View className="h-full w-1/2 items-center justify-center ">
<Ionicons
name={leftIcon ?? "sunny"}
size={20}
color={leftIconColor ?? "#fff"}
/>
</View>
<View className="h-full w-1/2 items-center justify-center ">
<Ionicons
name={rightIcon ?? "moon"}
size={20}
color={rightIconColor ?? "#fff"}
/>
</View>
</View>
</Animated.View>
</Pressable>
);
};
const styles = StyleSheet.create({
pressable: {
alignSelf: "flex-start",
},
shadowWrapper: {
justifyContent: "center",
position: "relative",
shadowColor: "#000",
shadowOpacity: 0.15,
shadowOffset: { width: 0, height: 4 },
shadowRadius: 6,
elevation: 6,
backgroundColor: "transparent",
},
container: {
flex: 1,
justifyContent: "center",
position: "relative",
overflow: "hidden",
},
knob: {
position: "absolute",
top: 0,
left: 0,
},
});
export default SliceSwitch;