* fix(app): keep mobile sidebars synced with store Gesture-owned skip flags could swallow the next store sync after a no-op or overlapping gesture, leaving React state and Reanimated shared values split. Always reconcile both mobile sidebars from the panel store after gesture animations. * test(app): keep sidebar sync coverage pure * fix(app): keep sidebar close transitions idempotent * fix(app): keep mobile sidebars stable through interruptions Independent React and worklet state let stale gesture or animation callbacks overwrite newer panel commands. Use one revisioned target and one transient motion runtime so outdated callbacks cannot restore an earlier panel.
84 lines
2.7 KiB
TypeScript
84 lines
2.7 KiB
TypeScript
import { useState, useCallback, useEffect, useId, useRef } from "react";
|
|
import {
|
|
type LayoutChangeEvent,
|
|
type NativeSyntheticEvent,
|
|
type NativeScrollEvent,
|
|
type StyleProp,
|
|
type ViewStyle,
|
|
} from "react-native";
|
|
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
|
|
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
|
|
import { useFileExplorerCloseGestureRef } from "@/mobile-panels/gestures";
|
|
|
|
interface DiffScrollProps {
|
|
children: React.ReactNode;
|
|
scrollViewWidth: number;
|
|
onScrollViewWidthChange: (width: number) => void;
|
|
style?: StyleProp<ViewStyle>;
|
|
contentContainerStyle?: StyleProp<ViewStyle>;
|
|
}
|
|
|
|
export function DiffScroll({
|
|
children,
|
|
scrollViewWidth: _scrollViewWidth,
|
|
onScrollViewWidthChange,
|
|
style,
|
|
contentContainerStyle,
|
|
}: DiffScrollProps) {
|
|
const [isAtLeftEdge, setIsAtLeftEdge] = useState(true);
|
|
const horizontalScroll = useHorizontalScrollOptional();
|
|
const scrollId = useId();
|
|
const scrollViewRef = useRef<ScrollViewType>(null);
|
|
|
|
const closeGestureRef = useFileExplorerCloseGestureRef();
|
|
|
|
// Register/unregister scroll offset tracking
|
|
useEffect(() => {
|
|
if (!horizontalScroll) return;
|
|
// Start at 0 (not scrolled)
|
|
horizontalScroll.registerScrollOffset(scrollId, 0);
|
|
return () => {
|
|
horizontalScroll.unregisterScrollOffset(scrollId);
|
|
};
|
|
}, [horizontalScroll, scrollId]);
|
|
|
|
const handleScroll = useCallback(
|
|
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
const offsetX = event.nativeEvent.contentOffset.x;
|
|
// Track if we're at the left edge (with small threshold for float precision)
|
|
setIsAtLeftEdge(offsetX <= 1);
|
|
if (horizontalScroll) {
|
|
horizontalScroll.registerScrollOffset(scrollId, offsetX);
|
|
}
|
|
},
|
|
[horizontalScroll, scrollId],
|
|
);
|
|
|
|
const handleLayout = useCallback(
|
|
(e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width),
|
|
[onScrollViewWidthChange],
|
|
);
|
|
|
|
return (
|
|
<ScrollView
|
|
ref={scrollViewRef}
|
|
horizontal
|
|
nestedScrollEnabled
|
|
showsHorizontalScrollIndicator
|
|
bounces={false}
|
|
style={style}
|
|
contentContainerStyle={contentContainerStyle}
|
|
onScroll={handleScroll}
|
|
scrollEventThrottle={16}
|
|
onLayout={handleLayout}
|
|
// When at left edge, wait for close gesture to fail before scrolling.
|
|
// The close gesture fails quickly on leftward swipes (failOffsetX=-10),
|
|
// so scrolling left works normally. On rightward swipes, close gesture
|
|
// activates and closes the sidebar.
|
|
waitFor={isAtLeftEdge && closeGestureRef?.current ? closeGestureRef : undefined}
|
|
>
|
|
{children}
|
|
</ScrollView>
|
|
);
|
|
}
|