refactor(scrollbar): improve scroll tracking and animation timing
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user