247 lines
6.5 KiB
TypeScript
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;
|