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

View File

@@ -703,7 +703,6 @@ export function AgentStreamView({
offset: nextOffset,
animated: false,
});
streamScrollbarMetrics.setOffset(nextOffset);
}}
/>

View File

@@ -210,7 +210,6 @@ export function DraggableList<T>({
metrics={scrollbarMetrics}
onScrollToOffset={(nextOffset) => {
scrollViewRef.current?.scrollTo({ y: nextOffset, animated: false });
scrollbarMetrics.setOffset(nextOffset);
}}
/>
</View>

View File

@@ -764,7 +764,6 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
offset: nextOffset,
animated: false,
});
treeScrollbarMetrics.setOffset(nextOffset);
}}
/>
</Animated.View>
@@ -827,7 +826,6 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
offset: nextOffset,
animated: false,
});
treeScrollbarMetrics.setOffset(nextOffset);
}}
/>
</View>

View File

@@ -1413,7 +1413,6 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
offset: nextOffset,
animated: false,
});
diffScrollbarMetrics.setOffset(nextOffset);
}}
/>
</View>

View File

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