diff --git a/src/components/Switch.tsx b/src/components/Switch.tsx index 0d5d92db8996c..028e100849777 100644 --- a/src/components/Switch.tsx +++ b/src/components/Switch.tsx @@ -1,5 +1,5 @@ -import React, {useEffect, useMemo} from 'react'; -import Animated, {interpolateColor, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; +import React, {useLayoutEffect, useMemo, useRef} from 'react'; +import Animated, {cancelAnimation, interpolateColor, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; @@ -40,9 +40,27 @@ function Switch({isOn, onToggle, accessibilityLabel, disabled, showLockIcon, dis const theme = useTheme(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['Lock']); - useEffect(() => { - offsetX.set(withTiming(isOn ? OFFSET_X.ON : OFFSET_X.OFF, {duration: 300})); - }, [isOn, offsetX]); + const targetOffsetX = isOn ? OFFSET_X.ON : OFFSET_X.OFF; + const prevIsOn = useRef(isOn); + const hasUserToggled = useRef(false); + + // Track when user toggles vs when props change due to recycling + useLayoutEffect(() => { + if (prevIsOn.current === isOn) { + return; + } + if (hasUserToggled.current) { + // User just toggled - animate to new position + offsetX.set(withTiming(targetOffsetX, {duration: 300})); + hasUserToggled.current = false; + } else { + // Props changed due to list recycling - immediately set position without animation + // This prevents the visual glitch where switches appear to auto-toggle during scrolling + cancelAnimation(offsetX); + offsetX.set(targetOffsetX); + } + prevIsOn.current = isOn; + }, [isOn, offsetX, targetOffsetX]); const handleSwitchPress = () => { requestAnimationFrame(() => { @@ -50,7 +68,14 @@ function Switch({isOn, onToggle, accessibilityLabel, disabled, showLockIcon, dis disabledAction?.(); return; } + hasUserToggled.current = true; onToggle(!isOn); + // If onToggle doesn't result in an isOn change (e.g., a modal is shown instead), + // useLayoutEffect won't fire to clear hasUserToggled. Clear it in the next frame + // to prevent stale flags from misclassifying future recycled prop changes. + requestAnimationFrame(() => { + hasUserToggled.current = false; + }); }); };