feat: add deterministic scroll drive controller
This commit is contained in:
87
src/composables/useScrollDrive.ts
Normal file
87
src/composables/useScrollDrive.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { onMounted, onUnmounted, readonly, shallowRef, type Ref } from 'vue';
|
||||
import { createScrollDriveController } from '@/journey/scroll-drive-controller';
|
||||
import type { ScrollDriveSample, ScrollDriveSnapshot } from '@/journey/types';
|
||||
|
||||
const PASSIVE_LISTENER: AddEventListenerOptions = { passive: true };
|
||||
|
||||
const initialSnapshot = (): ScrollDriveSnapshot => ({
|
||||
scrollY: 0,
|
||||
routeProgress: 0,
|
||||
direction: 0,
|
||||
acceleration: 0,
|
||||
driveSpeed: 0.18,
|
||||
});
|
||||
|
||||
export function useScrollDrive(): Readonly<Ref<ScrollDriveSnapshot>> {
|
||||
const controller = createScrollDriveController();
|
||||
const snapshot = shallowRef<ScrollDriveSnapshot>(initialSnapshot());
|
||||
let teardown = (): void => {};
|
||||
|
||||
onMounted(() => {
|
||||
let frameId: number | undefined;
|
||||
let resetOnNextFrame = false;
|
||||
const visualViewport = window.visualViewport;
|
||||
|
||||
const readSample = (now: number): ScrollDriveSample => {
|
||||
const scrollingElement = document.scrollingElement ?? document.documentElement;
|
||||
const viewportHeight = visualViewport?.height ?? document.documentElement.clientHeight;
|
||||
return {
|
||||
scrollY: scrollingElement.scrollTop,
|
||||
now,
|
||||
maxScrollY: Math.max(0, scrollingElement.scrollHeight - viewportHeight),
|
||||
};
|
||||
};
|
||||
|
||||
const runFrame = (now: number): void => {
|
||||
frameId = undefined;
|
||||
const input = readSample(now);
|
||||
snapshot.value = resetOnNextFrame
|
||||
? controller.reset(input)
|
||||
: controller.sample(input);
|
||||
resetOnNextFrame = false;
|
||||
};
|
||||
|
||||
const scheduleFrame = (): void => {
|
||||
if (frameId === undefined && document.visibilityState !== 'hidden') {
|
||||
frameId = requestAnimationFrame(runFrame);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVisibilityChange = (): void => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
resetOnNextFrame = true;
|
||||
if (frameId !== undefined) {
|
||||
cancelAnimationFrame(frameId);
|
||||
frameId = undefined;
|
||||
}
|
||||
snapshot.value = controller.reset();
|
||||
return;
|
||||
}
|
||||
resetOnNextFrame = true;
|
||||
scheduleFrame();
|
||||
};
|
||||
|
||||
snapshot.value = controller.reset(readSample(performance.now()));
|
||||
window.addEventListener('scroll', scheduleFrame, PASSIVE_LISTENER);
|
||||
window.addEventListener('resize', scheduleFrame, PASSIVE_LISTENER);
|
||||
visualViewport?.addEventListener('resize', scheduleFrame, PASSIVE_LISTENER);
|
||||
visualViewport?.addEventListener('scroll', scheduleFrame, PASSIVE_LISTENER);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange, PASSIVE_LISTENER);
|
||||
|
||||
teardown = (): void => {
|
||||
window.removeEventListener('scroll', scheduleFrame, PASSIVE_LISTENER);
|
||||
window.removeEventListener('resize', scheduleFrame, PASSIVE_LISTENER);
|
||||
visualViewport?.removeEventListener('resize', scheduleFrame, PASSIVE_LISTENER);
|
||||
visualViewport?.removeEventListener('scroll', scheduleFrame, PASSIVE_LISTENER);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange, PASSIVE_LISTENER);
|
||||
if (frameId !== undefined) {
|
||||
cancelAnimationFrame(frameId);
|
||||
frameId = undefined;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
onUnmounted(() => teardown());
|
||||
|
||||
return readonly(snapshot);
|
||||
}
|
||||
113
src/journey/scroll-drive-controller.ts
Normal file
113
src/journey/scroll-drive-controller.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type {
|
||||
ScrollDriveController,
|
||||
ScrollDriveSample,
|
||||
ScrollDriveSnapshot,
|
||||
} from './types';
|
||||
|
||||
const MAX_RAW_SPEED = 2.5;
|
||||
const RISE_TIME_CONSTANT_MS = 90;
|
||||
const DECAY_TIME_CONSTANT_MS = 420;
|
||||
const CRUISE_SPEED = 0.18;
|
||||
const ACCELERATION_SPEED_RANGE = 0.82;
|
||||
const BACKGROUND_GAP_MS = 250;
|
||||
|
||||
const clamp = (value: number, minimum: number, maximum: number): number =>
|
||||
Math.min(maximum, Math.max(minimum, value));
|
||||
|
||||
const isFiniteSample = (input: ScrollDriveSample): boolean =>
|
||||
Number.isFinite(input.scrollY) &&
|
||||
Number.isFinite(input.now) &&
|
||||
Number.isFinite(input.maxScrollY);
|
||||
|
||||
const initialSnapshot = (): ScrollDriveSnapshot => ({
|
||||
scrollY: 0,
|
||||
routeProgress: 0,
|
||||
direction: 0,
|
||||
acceleration: 0,
|
||||
driveSpeed: CRUISE_SPEED,
|
||||
});
|
||||
|
||||
const routeProgress = (input: ScrollDriveSample): number =>
|
||||
input.maxScrollY > 0
|
||||
? clamp(input.scrollY / Math.max(1, input.maxScrollY), 0, 1)
|
||||
: 0;
|
||||
|
||||
const cruiseSnapshot = (input: ScrollDriveSample): ScrollDriveSnapshot => ({
|
||||
scrollY: input.scrollY,
|
||||
routeProgress: routeProgress(input),
|
||||
direction: 0,
|
||||
acceleration: 0,
|
||||
driveSpeed: CRUISE_SPEED,
|
||||
});
|
||||
|
||||
export function createScrollDriveController(): ScrollDriveController {
|
||||
let previousSample: ScrollDriveSample | undefined;
|
||||
let smoothedAcceleration = 0;
|
||||
|
||||
const reset = (input?: ScrollDriveSample): ScrollDriveSnapshot => {
|
||||
smoothedAcceleration = 0;
|
||||
previousSample = input !== undefined && isFiniteSample(input) ? { ...input } : undefined;
|
||||
return previousSample === undefined ? initialSnapshot() : cruiseSnapshot(previousSample);
|
||||
};
|
||||
|
||||
const sample = (input: ScrollDriveSample): ScrollDriveSnapshot => {
|
||||
if (!isFiniteSample(input)) {
|
||||
smoothedAcceleration = 0;
|
||||
return initialSnapshot();
|
||||
}
|
||||
|
||||
if (input.maxScrollY <= 0) {
|
||||
smoothedAcceleration = 0;
|
||||
previousSample = { ...input };
|
||||
return cruiseSnapshot(input);
|
||||
}
|
||||
|
||||
if (previousSample === undefined) {
|
||||
previousSample = { ...input };
|
||||
return cruiseSnapshot(input);
|
||||
}
|
||||
|
||||
const elapsedMs = input.now - previousSample.now;
|
||||
if (elapsedMs <= 0 || !Number.isFinite(elapsedMs)) {
|
||||
smoothedAcceleration = 0;
|
||||
return cruiseSnapshot(input);
|
||||
}
|
||||
|
||||
if (elapsedMs > BACKGROUND_GAP_MS) {
|
||||
previousSample = { ...input };
|
||||
smoothedAcceleration = 0;
|
||||
return cruiseSnapshot(input);
|
||||
}
|
||||
|
||||
const deltaY = input.scrollY - previousSample.scrollY;
|
||||
const rawSpeed = Math.abs(deltaY) / elapsedMs;
|
||||
const impulse = clamp(rawSpeed / MAX_RAW_SPEED, 0, 1);
|
||||
const timeConstantMs = impulse > smoothedAcceleration
|
||||
? RISE_TIME_CONSTANT_MS
|
||||
: DECAY_TIME_CONSTANT_MS;
|
||||
const alpha = 1 - Math.exp(-elapsedMs / timeConstantMs);
|
||||
smoothedAcceleration += (impulse - smoothedAcceleration) * alpha;
|
||||
previousSample = { ...input };
|
||||
|
||||
const direction: -1 | 0 | 1 = deltaY > 0 ? 1 : deltaY < 0 ? -1 : 0;
|
||||
return {
|
||||
scrollY: input.scrollY,
|
||||
routeProgress: routeProgress(input),
|
||||
direction,
|
||||
acceleration: smoothedAcceleration,
|
||||
driveSpeed: clamp(
|
||||
CRUISE_SPEED + ACCELERATION_SPEED_RANGE * smoothedAcceleration,
|
||||
CRUISE_SPEED,
|
||||
1,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
return { sample, reset };
|
||||
}
|
||||
|
||||
export type {
|
||||
ScrollDriveController,
|
||||
ScrollDriveSample,
|
||||
ScrollDriveSnapshot,
|
||||
} from './types';
|
||||
18
src/journey/types.ts
Normal file
18
src/journey/types.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export interface ScrollDriveSample {
|
||||
scrollY: number;
|
||||
now: number;
|
||||
maxScrollY: number;
|
||||
}
|
||||
|
||||
export interface ScrollDriveSnapshot {
|
||||
scrollY: number;
|
||||
routeProgress: number;
|
||||
direction: -1 | 0 | 1;
|
||||
acceleration: number;
|
||||
driveSpeed: number;
|
||||
}
|
||||
|
||||
export interface ScrollDriveController {
|
||||
sample(input: ScrollDriveSample): ScrollDriveSnapshot;
|
||||
reset(input?: ScrollDriveSample): ScrollDriveSnapshot;
|
||||
}
|
||||
Reference in New Issue
Block a user