Skip to content

Commit

Permalink
improve click scroll behavior
Browse files Browse the repository at this point in the history
  • Loading branch information
KingSora committed Jul 29, 2024
1 parent 0c14bde commit b78db4f
Show file tree
Hide file tree
Showing 5 changed files with 113 additions and 67 deletions.
4 changes: 2 additions & 2 deletions packages/overlayscrollbars/src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,9 +195,9 @@ export type Options = {
autoHideDelay: number;
/** Whether the scrollbars auto hide behavior is suspended until a scroll happened. */
autoHideSuspend: boolean;
/** Whether its possible to drag the handle of a scrollbar to scroll the viewport. */
/** Whether it is possible to drag the handle of a scrollbar to scroll the viewport. */
dragScroll: boolean;
/** Whether its possible to click the track of a scrollbar to scroll the viewport. */
/** Whether it is possible to click the track of a scrollbar to scroll the viewport. */
clickScroll: boolean;
/**
* An array of pointer types which shall be supported.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { animateNumber, noop, selfClearTimeout } from '~/support';
import type { EasingFn } from '~/support';
import type { StaticPlugin } from '~/plugins';

export const clickScrollPluginModuleName = '__osClickScrollPlugin';
Expand All @@ -10,65 +9,87 @@ export const ClickScrollPlugin = /* @__PURE__ */ (() => ({
() =>
(
moveHandleRelative: (deltaMovement: number) => void,
getHandleOffset: (handleRect?: DOMRect, trackRect?: DOMRect) => number,
startOffset: number,
targetOffset: number,
handleLength: number,
relativeTrackPointerOffset: number
onClickScrollCompleted: (stopped: boolean) => void
) => {
// click scroll animation has 2 parts:
// click scroll animation has 2 main parts:
// 1. the "click" which scrolls 100% of the viewport in a certain amount of time
// 2. the "press" which scrolls to the point where the cursor is located, the "press" always waits for the "click" to finish
// The "click" should not be canceled by a "pointerup" event because very fast clicks or taps would cancel it too fast
// The "click" should only be canceled by a subsequent "pointerdown" event because otherwise 2 animations would run
// The "press" should be canceld by the next "pointerup" event
let stop = false;
let stopClickAnimation = noop;

let stopped = false;
let stopPressAnimation = noop;
const [setFirstIterationPauseTimeout, clearFirstIterationPauseTimeout] =
selfClearTimeout(133);
const animateClickScroll = (
clickScrollProgress: number,
iteration: number,
easing?: EasingFn | false
) =>
const linearScrollMs = 133;
const easedScrollMs = 222;
const [setPressAnimationTimeout, clearPressAnimationTimeout] =
selfClearTimeout(linearScrollMs);
const targetOffsetSign = Math.sign(targetOffset);
const handleLengthWithTargetSign = handleLength * targetOffsetSign;
const handleLengthWithTargetSignHalf = handleLengthWithTargetSign / 2;
const easing = (x: number) => 1 - (1 - x) * (1 - x); // easeOutQuad;
const easedEndPressAnimation = (from: number, to: number) =>
animateNumber(from, to, easedScrollMs, moveHandleRelative, easing);
const linearPressAnimation = (linearFrom: number, msFactor: number) =>
animateNumber(
clickScrollProgress,
clickScrollProgress + handleLength * Math.sign(startOffset),
iteration ? 133 : 222,
(animationProgress, _, animationCompleted) => {
moveHandleRelative(animationProgress);
const handleStartBound = getHandleOffset();
const handleEndBound = handleStartBound + handleLength;
const mouseBetweenHandleBounds =
relativeTrackPointerOffset >= handleStartBound &&
relativeTrackPointerOffset <= handleEndBound;
const animationCompletedAction = () => {
stopPressAnimation = animateClickScroll(animationProgress, iteration + 1);
};
linearFrom,
targetOffset - handleLengthWithTargetSign,
linearScrollMs * msFactor,
(progress, _, completed) => {
moveHandleRelative(progress);

if (!stop && animationCompleted && !mouseBetweenHandleBounds) {
if (iteration) {
animationCompletedAction();
} else {
setFirstIterationPauseTimeout(animationCompletedAction);
}
if (completed) {
stopPressAnimation = easedEndPressAnimation(progress, targetOffset);
}
},
easing
}
);
const stopClickAnimation = animateNumber(
0,
handleLengthWithTargetSign,
easedScrollMs,
(clickAnimationProgress, _, clickAnimationCompleted) => {
moveHandleRelative(clickAnimationProgress);

if (clickAnimationCompleted) {
onClickScrollCompleted(stopped);

// easeOutQuad
stopClickAnimation = animateClickScroll(0, 0, (x) => 1 - (1 - x) * (1 - x));
if (!stopped) {
const remainingScrollDistance = targetOffset - clickAnimationProgress;
const continueWithPress =
Math.sign(remainingScrollDistance - handleLengthWithTargetSignHalf) ===
targetOffsetSign;

continueWithPress &&
setPressAnimationTimeout(() => {
const remainingLinearScrollDistance =
remainingScrollDistance - handleLengthWithTargetSign;
const linearBridge =
Math.sign(remainingLinearScrollDistance) === targetOffsetSign;

stopPressAnimation = linearBridge
? linearPressAnimation(
clickAnimationProgress,
Math.abs(remainingLinearScrollDistance) / handleLength
)
: easedEndPressAnimation(clickAnimationProgress, targetOffset);
});
}
}
},
easing
);

return (stopClick?: boolean) => {
stop = true;
clearFirstIterationPauseTimeout();
stopped = true;

if (stopClick) {
stopClickAnimation();
stopPressAnimation();
} else {
stopPressAnimation();
}

clearPressAnimationTimeout();
stopPressAnimation();
};
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,23 +135,26 @@ export const createScrollbarsSetupEvents = (
runEachAndClear(pointerupCleanupFns);
pointerCaptureElement.releasePointerCapture(pointerUpEvent.pointerId);
};
const nonAnimatedScroll = isDragScroll || instantClickScroll;
const revertScrollObscuringStyles = _removeScrollObscuringStyles();

const pointerupCleanupFns = [
() => {
const withoutSnapScrollOffset = getElementScroll(_scrollOffsetElement);
revertScrollObscuringStyles();
const withSnapScrollOffset = getElementScroll(_scrollOffsetElement);
const snapScrollDiff = {
x: withSnapScrollOffset.x - withoutSnapScrollOffset.x,
y: withSnapScrollOffset.y - withoutSnapScrollOffset.y,
};
if (nonAnimatedScroll) {
const withoutSnapScrollOffset = getElementScroll(_scrollOffsetElement);
revertScrollObscuringStyles();
const withSnapScrollOffset = getElementScroll(_scrollOffsetElement);
const snapScrollDiff = {
x: withSnapScrollOffset.x - withoutSnapScrollOffset.x,
y: withSnapScrollOffset.y - withoutSnapScrollOffset.y,
};

if (mathAbs(snapScrollDiff.x) > 3 || mathAbs(snapScrollDiff.y) > 3) {
_removeScrollObscuringStyles();
scrollElementTo(_scrollOffsetElement, withoutSnapScrollOffset);
scrollOffsetElementScrollBy(snapScrollDiff);
scrollSnapScrollTransitionTimeout(revertScrollObscuringStyles);
if (mathAbs(snapScrollDiff.x) > 3 || mathAbs(snapScrollDiff.y) > 3) {
_removeScrollObscuringStyles();
scrollElementTo(_scrollOffsetElement, withoutSnapScrollOffset);
scrollOffsetElementScrollBy(snapScrollDiff);
scrollSnapScrollTransitionTimeout(revertScrollObscuringStyles);
}
}
},
addEventListener(_documentElm, releasePointerCaptureEvents, releasePointerCapture),
Expand All @@ -162,7 +165,7 @@ export const createScrollbarsSetupEvents = (
addEventListener(_track, 'pointermove', (pointerMoveEvent: PointerEvent) => {
const relativeMovement = pointerMoveEvent[clientXYKey] - pointerDownOffset;

if (isDragScroll || instantClickScroll) {
if (nonAnimatedScroll) {
moveHandleRelative(startOffset + relativeMovement);
}
}),
Expand All @@ -179,13 +182,19 @@ export const createScrollbarsSetupEvents = (
if (animateClickScroll) {
const stopClickScrollAnimation = animateClickScroll(
moveHandleRelative,
getHandleOffset,
startOffset,
handleLength,
relativeTrackPointerOffset
(stopped) => {
// if the scroll animation doesn't continue with a press
if (stopped) {
revertScrollObscuringStyles();
} else {
push(pointerupCleanupFns, revertScrollObscuringStyles);
}
}
);

push(pointerupCleanupFns, bind(stopClickScrollAnimation));
push(pointerupCleanupFns, stopClickScrollAnimation);
push(pointerdownCleanupFns, bind(stopClickScrollAnimation, true));
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/overlayscrollbars/src/support/utils/array.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ export const deduplicateArray = <T extends any[]>(array: T): T => from(new Set(a
*/
export const runEachAndClear = (arr: RunEachItem[], args?: any[], keep?: boolean): void => {
// eslint-disable-next-line prefer-spread
const runFn = (fn: RunEachItem) => fn && fn.apply(undefined, args || []);
const runFn = (fn: RunEachItem) => (fn ? fn.apply(undefined, args || []) : true); // return true when fn is falsy to not break the loop
each(arr, runFn);
!keep && ((arr as any[]).length = 0);
};
Original file line number Diff line number Diff line change
Expand Up @@ -280,15 +280,31 @@ describe('array utilities', () => {

describe('runEachAndClear', () => {
test('array', () => {
const arr = [jest.fn(), null, jest.fn(), undefined, jest.fn()];
const firstFn = jest.fn();
const middleFn = jest.fn();
const lastFn = jest.fn();
const arr = [
firstFn,
false as const,
null,
undefined,
middleFn,
undefined,
null,
false as const,
lastFn,
];
runEachAndClear(arr, ['a', 'b', 'c', 'd'], true);
arr.forEach((fn) => {
if (fn) {
expect(fn).toHaveBeenCalledWith('a', 'b', 'c', 'd');
}
});
runEachAndClear(arr, ['a', 'b', 'c', 'd']);

expect(arr.length).toBe(0);

expect(firstFn).toHaveBeenCalledTimes(2);
expect(firstFn).toHaveBeenCalledWith('a', 'b', 'c', 'd');
expect(middleFn).toHaveBeenCalledTimes(2);
expect(middleFn).toHaveBeenCalledWith('a', 'b', 'c', 'd');
expect(lastFn).toHaveBeenCalledTimes(2);
expect(lastFn).toHaveBeenCalledWith('a', 'b', 'c', 'd');
});
});

Expand Down

0 comments on commit b78db4f

Please sign in to comment.