Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 30 additions & 5 deletions src/components/Switch.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -40,17 +40,42 @@ 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(() => {
if (disabled) {
disabledAction?.();
return;
}
hasUserToggled.current = true;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reset user-toggle flag when toggle is rejected

hasUserToggled.current is set before onToggle runs, but it is only cleared later when isOn actually changes in useLayoutEffect. In flows where onToggle intentionally exits without changing state (for example, category toggles that show a modal and return when trying to disable the last enabled category), this flag stays true and the next recycled isOn prop change is misclassified as a user action, so the switch animates during scroll again. This reintroduces the visual glitch the commit is trying to prevent.

Useful? React with 👍 / 👎.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This indeed might cause regression. Can you check?

Example case:

Screen.Recording.2026-02-22.at.11.22.54.PM.mov

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@situchan I updated. Please help to check again. Thanks!

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;
});
});
};

Expand Down
Loading