import { useEffect, useMemo, useRef, useState } from "react"; import { Animated, Image, ImageSourcePropType, Pressable, StyleProp, StyleSheet, View, ViewStyle, } from "react-native"; const AnimatedImage = Animated.createAnimatedComponent(Image); 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; const DEFAULT_OFF_IMAGE = "https://images.vexels.com/media/users/3/163966/isolated/preview/6ecbb5ec8c121c0699c9b9179d6b24aa-england-flag-language-icon-circle.png"; const DEFAULT_ON_IMAGE = "https://cdn-icons-png.flaticon.com/512/197/197473.png"; const DEFAULT_INACTIVE_BG = "#D3DAD9"; const DEFAULT_ACTIVE_BG = "#C2E2FA"; const PRESSED_SCALE = 0.96; const PRESS_FEEDBACK_DURATION = 120; type RotateSwitchProps = { size?: SwitchSize; onImage?: ImageSourcePropType | string; offImage?: ImageSourcePropType | string; initialValue?: boolean; duration?: number; activeBackgroundColor?: string; inactiveBackgroundColor?: string; style?: StyleProp; onChange?: (value: boolean) => void; }; const toImageSource = ( input: ImageSourcePropType | string | undefined, fallbackUri: string ): ImageSourcePropType => { if (typeof input === "string") { return { uri: input }; } if (input) { return input; } return { uri: fallbackUri }; }; const RotateSwitch = ({ size = "md", onImage, offImage, duration, activeBackgroundColor = DEFAULT_ACTIVE_BG, inactiveBackgroundColor = DEFAULT_INACTIVE_BG, initialValue = false, style, onChange, }: RotateSwitchProps) => { const { width: containerWidth, height: containerHeight } = SIZE_PRESETS[size] ?? SIZE_PRESETS.md; const knobSize = containerHeight; const knobTravel = containerWidth - knobSize; const animationDuration = Math.max(duration ?? DEFAULT_TOGGLE_DURATION, 0); const resolvedOffImage = useMemo( () => toImageSource(offImage, DEFAULT_OFF_IMAGE), [offImage] ); const resolvedOnImage = useMemo( () => toImageSource(onImage, DEFAULT_ON_IMAGE), [onImage] ); const [isOn, setIsOn] = useState(initialValue); const [bgOn, setBgOn] = useState(initialValue); const [displaySource, setDisplaySource] = useState( initialValue ? resolvedOnImage : resolvedOffImage ); const progress = useRef(new Animated.Value(initialValue ? 1 : 0)).current; const pressScale = useRef(new Animated.Value(1)).current; const listenerIdRef = useRef(null); useEffect(() => { setDisplaySource(bgOn ? resolvedOnImage : resolvedOffImage); }, [bgOn, resolvedOffImage, resolvedOnImage]); const removeProgressListener = () => { if (listenerIdRef.current != null) { progress.removeListener(listenerIdRef.current as string); listenerIdRef.current = null; } }; const attachHalfwaySwapListener = (next: boolean) => { removeProgressListener(); let swapped = false; listenerIdRef.current = progress.addListener(({ value }) => { if (swapped) return; const crossedHalfway = next ? value >= 0.5 : value <= 0.5; if (!crossedHalfway) return; swapped = true; setBgOn(next); setDisplaySource(next ? resolvedOnImage : resolvedOffImage); removeProgressListener(); }); }; // Clean up listener on unmount useEffect(() => { return () => { removeProgressListener(); }; }, []); // Keep internal state in sync when `initialValue` prop changes. // Users may pass a changing `initialValue` (like from parent state) and // expect the switch to reflect that. Animate `progress` toward the // corresponding value and update images/background when done. useEffect(() => { // If no change, do nothing if (initialValue === isOn) return; const next = initialValue; const targetValue = next ? 1 : 0; progress.stopAnimation(); removeProgressListener(); if (animationDuration <= 0) { progress.setValue(targetValue); setIsOn(next); setBgOn(next); setDisplaySource(next ? resolvedOnImage : resolvedOffImage); return; } // Update isOn immediately so accessibilityState etc. reflect change. setIsOn(next); attachHalfwaySwapListener(next); Animated.timing(progress, { toValue: targetValue, duration: animationDuration, useNativeDriver: true, }).start(() => { // Ensure final state reflects the target in case animation skips halfway listener. setBgOn(next); setDisplaySource(next ? resolvedOnImage : resolvedOffImage); }); }, [ initialValue, isOn, animationDuration, progress, resolvedOffImage, resolvedOnImage, ]); const knobTranslateX = progress.interpolate({ inputRange: [0, 1], outputRange: [0, knobTravel], }); const knobRotation = progress.interpolate({ inputRange: [0, 1], outputRange: ["0deg", "180deg"], }); const animatePress = (toValue: number) => { Animated.timing(pressScale, { toValue, duration: PRESS_FEEDBACK_DURATION, useNativeDriver: true, }).start(); }; const handlePressIn = () => { animatePress(PRESSED_SCALE); }; const handlePressOut = () => { animatePress(1); }; const handleToggle = () => { const next = !isOn; const targetValue = next ? 1 : 0; progress.stopAnimation(); removeProgressListener(); if (animationDuration <= 0) { progress.setValue(targetValue); setIsOn(next); setBgOn(next); onChange?.(next); return; } setIsOn(next); attachHalfwaySwapListener(next); Animated.timing(progress, { toValue: targetValue, duration: animationDuration, useNativeDriver: true, }).start(() => { setBgOn(next); onChange?.(next); }); }; return ( ); }; 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 RotateSwitch;