308 lines
7.6 KiB
TypeScript
308 lines
7.6 KiB
TypeScript
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<ViewStyle>;
|
|
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<ImageSourcePropType>(
|
|
initialValue ? resolvedOnImage : resolvedOffImage
|
|
);
|
|
|
|
const progress = useRef(new Animated.Value(initialValue ? 1 : 0)).current;
|
|
const pressScale = useRef(new Animated.Value(1)).current;
|
|
const listenerIdRef = useRef<string | number | null>(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 (
|
|
<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,
|
|
{
|
|
borderRadius: containerHeight / 2,
|
|
backgroundColor: bgOn
|
|
? activeBackgroundColor
|
|
: inactiveBackgroundColor,
|
|
},
|
|
]}
|
|
>
|
|
<AnimatedImage
|
|
source={displaySource}
|
|
style={[
|
|
styles.knob,
|
|
{
|
|
width: knobSize,
|
|
height: knobSize,
|
|
borderRadius: knobSize / 2,
|
|
transform: [
|
|
{ translateX: knobTranslateX },
|
|
{ rotate: knobRotation },
|
|
],
|
|
},
|
|
]}
|
|
/>
|
|
</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 RotateSwitch;
|