refactor(scrollbar): improve scroll tracking and animation timing

This commit is contained in:
Mohamed Boudra
2026-02-19 09:02:14 +07:00
parent a1fdeeb7ae
commit 02bcfcd5af
5 changed files with 74 additions and 42 deletions
@@ -703,7 +703,6 @@ export function AgentStreamView({
offset: nextOffset, offset: nextOffset,
animated: false, animated: false,
}); });
streamScrollbarMetrics.setOffset(nextOffset);
}} }}
/> />
@@ -210,7 +210,6 @@ export function DraggableList<T>({
metrics={scrollbarMetrics} metrics={scrollbarMetrics}
onScrollToOffset={(nextOffset) => { onScrollToOffset={(nextOffset) => {
scrollViewRef.current?.scrollTo({ y: nextOffset, animated: false }); scrollViewRef.current?.scrollTo({ y: nextOffset, animated: false });
scrollbarMetrics.setOffset(nextOffset);
}} }}
/> />
</View> </View>
@@ -764,7 +764,6 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
offset: nextOffset, offset: nextOffset,
animated: false, animated: false,
}); });
treeScrollbarMetrics.setOffset(nextOffset);
}} }}
/> />
</Animated.View> </Animated.View>
@@ -827,7 +826,6 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
offset: nextOffset, offset: nextOffset,
animated: false, animated: false,
}); });
treeScrollbarMetrics.setOffset(nextOffset);
}} }}
/> />
</View> </View>
@@ -1413,7 +1413,6 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
offset: nextOffset, offset: nextOffset,
animated: false, animated: false,
}); });
diffScrollbarMetrics.setOffset(nextOffset);
}} }}
/> />
</View> </View>
@@ -21,9 +21,11 @@ const HANDLE_GRAB_VERTICAL_PADDING = 8;
const HANDLE_OPACITY_VISIBLE = 0.62; const HANDLE_OPACITY_VISIBLE = 0.62;
const HANDLE_OPACITY_HOVERED = 0.78; const HANDLE_OPACITY_HOVERED = 0.78;
const HANDLE_OPACITY_DRAGGING = 0.9; const HANDLE_OPACITY_DRAGGING = 0.9;
const HANDLE_TRAVEL_TRANSITION_DURATION_MS = 90;
const HANDLE_FADE_DURATION_MS = 220; const HANDLE_FADE_DURATION_MS = 220;
const HANDLE_WIDTH_TRANSITION_DURATION_MS = 240; const HANDLE_WIDTH_TRANSITION_DURATION_MS = 240;
const HANDLE_SCROLL_VISIBILITY_MS = 1200; const HANDLE_SCROLL_VISIBILITY_MS = 1200;
const HANDLE_SCROLL_ACTIVE_MS = 110;
function readClientY(event: any): number | null { function readClientY(event: any): number | null {
const value = const value =
@@ -128,9 +130,11 @@ export function WebDesktopScrollbarOverlay({
const [isHandleHovered, setIsHandleHovered] = useState(false); const [isHandleHovered, setIsHandleHovered] = useState(false);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [isScrollVisible, setIsScrollVisible] = useState(false); const [isScrollVisible, setIsScrollVisible] = useState(false);
const [isScrollActive, setIsScrollActive] = useState(false);
const dragStartOffsetRef = useRef(0); const dragStartOffsetRef = useRef(0);
const dragStartClientYRef = useRef(0); const dragStartClientYRef = useRef(0);
const scrollVisibilityTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const scrollVisibilityTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const scrollActiveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastObservedOffsetRef = useRef<number | null>(null); const lastObservedOffsetRef = useRef<number | null>(null);
const geometryRef = useRef({ const geometryRef = useRef({
maxHandleOffset: 0, maxHandleOffset: 0,
@@ -143,6 +147,7 @@ export function WebDesktopScrollbarOverlay({
const normalizedOffset = inverted const normalizedOffset = inverted
? Math.max(0, maxScrollOffset - clamp(metrics.offset, 0, maxScrollOffset)) ? Math.max(0, maxScrollOffset - clamp(metrics.offset, 0, maxScrollOffset))
: clamp(metrics.offset, 0, maxScrollOffset); : clamp(metrics.offset, 0, maxScrollOffset);
const normalizedOffsetRef = useRef(normalizedOffset);
const geometry = useMemo( const geometry = useMemo(
() => () =>
@@ -165,6 +170,10 @@ export function WebDesktopScrollbarOverlay({
onScrollToOffsetRef.current = onScrollToOffset; onScrollToOffsetRef.current = onScrollToOffset;
}, [onScrollToOffset]); }, [onScrollToOffset]);
useEffect(() => {
normalizedOffsetRef.current = normalizedOffset;
}, [normalizedOffset]);
const clearScrollVisibilityTimeout = useCallback(() => { const clearScrollVisibilityTimeout = useCallback(() => {
if (scrollVisibilityTimeoutRef.current === null) { if (scrollVisibilityTimeoutRef.current === null) {
return; return;
@@ -173,6 +182,14 @@ export function WebDesktopScrollbarOverlay({
scrollVisibilityTimeoutRef.current = null; scrollVisibilityTimeoutRef.current = null;
}, []); }, []);
const clearScrollActiveTimeout = useCallback(() => {
if (scrollActiveTimeoutRef.current === null) {
return;
}
clearTimeout(scrollActiveTimeoutRef.current);
scrollActiveTimeoutRef.current = null;
}, []);
const revealScrollbarFromScroll = useCallback(() => { const revealScrollbarFromScroll = useCallback(() => {
setIsScrollVisible(true); setIsScrollVisible(true);
clearScrollVisibilityTimeout(); clearScrollVisibilityTimeout();
@@ -182,10 +199,21 @@ export function WebDesktopScrollbarOverlay({
}, HANDLE_SCROLL_VISIBILITY_MS); }, HANDLE_SCROLL_VISIBILITY_MS);
}, [clearScrollVisibilityTimeout]); }, [clearScrollVisibilityTimeout]);
const markScrollActivity = useCallback(() => {
setIsScrollActive(true);
clearScrollActiveTimeout();
scrollActiveTimeoutRef.current = setTimeout(() => {
setIsScrollActive(false);
scrollActiveTimeoutRef.current = null;
}, HANDLE_SCROLL_ACTIVE_MS);
}, [clearScrollActiveTimeout]);
useEffect(() => { useEffect(() => {
if (!enabled || !geometry.isVisible) { if (!enabled || !geometry.isVisible) {
setIsScrollVisible(false); setIsScrollVisible(false);
setIsScrollActive(false);
clearScrollVisibilityTimeout(); clearScrollVisibilityTimeout();
clearScrollActiveTimeout();
lastObservedOffsetRef.current = null; lastObservedOffsetRef.current = null;
return; return;
} }
@@ -199,19 +227,23 @@ export function WebDesktopScrollbarOverlay({
return; return;
} }
revealScrollbarFromScroll(); revealScrollbarFromScroll();
markScrollActivity();
}, [ }, [
clearScrollActiveTimeout,
clearScrollVisibilityTimeout, clearScrollVisibilityTimeout,
enabled, enabled,
geometry.isVisible, geometry.isVisible,
markScrollActivity,
normalizedOffset, normalizedOffset,
revealScrollbarFromScroll, revealScrollbarFromScroll,
]); ]);
useEffect( useEffect(
() => () => { () => () => {
clearScrollActiveTimeout();
clearScrollVisibilityTimeout(); clearScrollVisibilityTimeout();
}, },
[clearScrollVisibilityTimeout] [clearScrollActiveTimeout, clearScrollVisibilityTimeout]
); );
const applyDragDelta = useCallback( const applyDragDelta = useCallback(
@@ -241,7 +273,7 @@ export function WebDesktopScrollbarOverlay({
onMoveShouldSetPanResponder: () => true, onMoveShouldSetPanResponder: () => true,
onPanResponderTerminationRequest: () => false, onPanResponderTerminationRequest: () => false,
onPanResponderGrant: () => { onPanResponderGrant: () => {
dragStartOffsetRef.current = normalizedOffset; dragStartOffsetRef.current = normalizedOffsetRef.current;
setIsDragging(true); setIsDragging(true);
}, },
onPanResponderMove: (_event, gestureState) => { onPanResponderMove: (_event, gestureState) => {
@@ -254,7 +286,7 @@ export function WebDesktopScrollbarOverlay({
setIsDragging(false); setIsDragging(false);
}, },
}); });
}, [applyDragDelta, isWeb, normalizedOffset]); }, [applyDragDelta, isWeb]);
const startWebDrag = useCallback( const startWebDrag = useCallback(
(event: any) => { (event: any) => {
@@ -268,11 +300,11 @@ export function WebDesktopScrollbarOverlay({
event?.preventDefault?.(); event?.preventDefault?.();
event?.stopPropagation?.(); event?.stopPropagation?.();
event?.nativeEvent?.preventDefault?.(); event?.nativeEvent?.preventDefault?.();
dragStartOffsetRef.current = normalizedOffset; dragStartOffsetRef.current = normalizedOffsetRef.current;
dragStartClientYRef.current = clientY; dragStartClientYRef.current = clientY;
setIsDragging(true); setIsDragging(true);
}, },
[isWeb, normalizedOffset] [isWeb]
); );
const handleGrabHoverIn = useCallback(() => { const handleGrabHoverIn = useCallback(() => {
@@ -329,26 +361,33 @@ export function WebDesktopScrollbarOverlay({
? theme.colors.palette.zinc[500] ? theme.colors.palette.zinc[500]
: theme.colors.palette.zinc[700]; : theme.colors.palette.zinc[700];
const handleCursor = isDragging ? "grabbing" : "grab"; const handleCursor = isDragging ? "grabbing" : "grab";
const grabAreaTop = Math.max(0, geometry.handleOffset - HANDLE_GRAB_VERTICAL_PADDING); const handleTravelDurationMs =
const grabAreaHeight = Math.min( isDragging || isScrollActive ? 0 : HANDLE_TRAVEL_TRANSITION_DURATION_MS;
metrics.viewportSize - grabAreaTop, const thumbRegionOffset = Math.max(0, geometry.handleOffset - HANDLE_GRAB_VERTICAL_PADDING);
const thumbRegionHeight = Math.min(
metrics.viewportSize - thumbRegionOffset,
geometry.handleSize + HANDLE_GRAB_VERTICAL_PADDING * 2 geometry.handleSize + HANDLE_GRAB_VERTICAL_PADDING * 2
); );
const handleInsetTop = Math.max(0, (thumbRegionHeight - geometry.handleSize) / 2);
return ( return (
<View style={styles.overlay} pointerEvents="box-none"> <View style={styles.overlay} pointerEvents="box-none">
<View <View
style={[ style={[
styles.handleGrabArea, styles.thumbRegion,
{ {
top: grabAreaTop, top: 0,
height: grabAreaHeight, height: thumbRegionHeight,
transform: [{ translateY: thumbRegionOffset }],
}, },
isWeb && isWeb &&
({ ({
cursor: handleCursor, cursor: handleCursor,
touchAction: "none", touchAction: "none",
userSelect: "none", userSelect: "none",
transitionProperty: "transform",
transitionDuration: `${handleTravelDurationMs}ms`,
transitionTimingFunction: "linear",
} as any), } as any),
]} ]}
pointerEvents={handleVisible ? "auto" : "none"} pointerEvents={handleVisible ? "auto" : "none"}
@@ -362,30 +401,28 @@ export function WebDesktopScrollbarOverlay({
onMouseLeave: handleGrabHoverOut, onMouseLeave: handleGrabHoverOut,
} as any) } as any)
: null)} : null)}
/> >
<View <View
style={[ style={[
styles.handle, styles.handle,
{ {
top: geometry.handleOffset, marginTop: handleInsetTop,
height: geometry.handleSize, height: geometry.handleSize,
width: handleWidth, width: handleWidth,
backgroundColor: handleColor, backgroundColor: handleColor,
opacity: handleOpacity, opacity: handleOpacity,
}, },
isWeb && isWeb &&
({ ({
cursor: handleCursor, transitionProperty: "opacity, width, background-color",
touchAction: "none", transitionDuration: `${HANDLE_FADE_DURATION_MS}ms, ${HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${HANDLE_FADE_DURATION_MS}ms`,
userSelect: "none", transitionTimingFunction:
transitionProperty: "opacity, width, background-color", "ease-out, cubic-bezier(0.22, 0.75, 0.2, 1), ease-out",
transitionDuration: `${HANDLE_FADE_DURATION_MS}ms, ${HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${HANDLE_FADE_DURATION_MS}ms`, } as any),
transitionTimingFunction: ]}
"ease-out, cubic-bezier(0.22, 0.75, 0.2, 1), ease-out", pointerEvents="none"
} as any), />
]} </View>
pointerEvents="none"
/>
</View> </View>
); );
} }
@@ -402,11 +439,11 @@ const styles = StyleSheet.create(() => ({
zIndex: 10, zIndex: 10,
}, },
handle: { handle: {
position: "absolute",
width: HANDLE_WIDTH_IDLE, width: HANDLE_WIDTH_IDLE,
borderRadius: 999, borderRadius: 999,
alignSelf: "center",
}, },
handleGrabArea: { thumbRegion: {
position: "absolute", position: "absolute",
right: -3, right: -3,
width: HANDLE_GRAB_WIDTH, width: HANDLE_GRAB_WIDTH,