Files
2026-10-14 04:20:24 +02:00

154 lines
7.3 KiB
JavaScript

// Copyright (C) 2026 half144 and PS5 React contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution term: see LICENSE-ATTRIBUTION.
// The scrolling state behind a ScrollView: focus reveals, the eased per-frame scroll through
// NativeUI.scrollTo (patches/embeddedReact-scroll-to.patch) and `onScrollTarget`. Free of React so
// tests can drive it with a stand-in NativeUI.
import {onFrame} from '../frame.js';
import {deferImageFetches} from '../native.js';
import {anchorOffset, catchUpSpeed, revealOffset, scrollStep} from './geometry.js';
// 32 logical px of a 1280-wide layout, in screen px like the layout rectangles.
const MARGIN = 32 / 1280;
// The engine cannot animate a scroll offset, so JavaScript steps it on every presented frame (the
// host's frame callback, not a timer that drifts against the display), in whole pixels: up to 40
// logical px per 60 Hz frame, reached in four frames, then braking to stop on the target. A held
// key scrolls at that one steady speed, which also bounds the strip the engine rasterizes per frame.
// The steps are 60 Hz ticks; a faster display shows positions between them, so speed is the same.
const MAX_LOGICAL_PX_PER_FRAME = 40;
const ACCEL_LOGICAL_PX = 12;
const BRAKE_LOGICAL_PX = 4;
const FRAME_MS = 1000 / 60;
// Images wait while the view moves and this long after it stops, like a browser's lazy loading: art
// that only flies past is never fetched; what the view stops on (with the rows the list mounts ahead)
// starts then. Cached art still draws during the scroll.
const IMAGE_DEFER_MS = 150;
function assignRef(ref, value) {
if (typeof ref === 'function') ref(value);
else if (ref) ref.current = value;
}
/** @param {import('./manager.js').Frame | null} parent */
export function createFrame(parent) {
const frame = {
parent, x: 0, y: 0, props: {}, forwardedRef: null, handle: null, viewport: null, timer: null, target: null,
speed: [0, 0], position: [0, 0], from: [0, 0], to: [0, 0], progress: 1, listeners: new Set(),
/**
* @param {import('./geometry.js').Rect} rect pre-scroll, like `viewport`
* @param {import('./geometry.js').Rect | null} anchor its `scrollAnchor` ancestor in this frame, aligned
* on a vertical frame's y axis; the x axis and horizontal frames always move the least
* @param {{instant?: boolean}} [options] `instant` moves there now instead of easing (a jump far
* down a list, whose rows on the way could not all mount)
*/
reveal(rect, anchor, {instant = false} = {}) {
const {viewport, handle} = frame;
if (!viewport || handle == null) return;
const [fromX, fromY, maxX, maxY] = NativeUI.scrollTo(handle, NaN, NaN);
const margin = Math.round(screen.width * MARGIN);
const x = revealOffset(frame.x, rect.x - viewport.x, rect.width, viewport.width, maxX, margin);
const y = anchor && !frame.props.horizontal
? anchorOffset(frame.y, rect.y - viewport.y, rect.height, anchor.y - viewport.y, viewport.height, maxY, margin)
: revealOffset(frame.y, rect.y - viewport.y, rect.height, viewport.height, maxY, margin);
if (instant) {
frame.stop();
const [atX, atY] = NativeUI.scrollTo(handle, Math.round(x), Math.round(y));
frame.x = Math.round(atX);
frame.y = Math.round(atY);
frame.position = [frame.x, frame.y];
} else {
if (x === frame.x && y === frame.y) return;
frame.x = x;
frame.y = y;
frame.animate(fromX, fromY, x, y);
}
frame.notify();
frame.props.onScrollTarget?.({x: frame.x, y: frame.y});
},
// A new target while scrolling only moves the target; the steps carry on at the current speed.
animate(fromX, fromY, toX, toY) {
frame.target = [Math.round(toX), Math.round(toY)];
if (frame.timer !== null) return;
const scale = screen.width / 1280;
const [maxSpeed, accel, brake] = [MAX_LOGICAL_PX_PER_FRAME, ACCEL_LOGICAL_PX, BRAKE_LOGICAL_PX]
.map(logical => Math.max(1, Math.round(logical * scale)));
frame.from = frame.to = frame.position = [Math.round(fromX), Math.round(fromY)];
frame.progress = 1;
frame.speed = [0, 0];
frame.timer = onFrame(elapsedMs => {
// Ticks covered by this frame; a late frame advances at most two, so a hitch is not followed by a jump.
frame.progress += Math.min(2, elapsedMs / FRAME_MS);
for (; frame.progress > 1 + 1e-6; frame.progress--) {
let [x, y] = frame.to, speedX, speedY;
const capX = catchUpSpeed(frame.target[0] - x, frame.viewport.width, maxSpeed);
const capY = catchUpSpeed(frame.target[1] - y, frame.viewport.height, maxSpeed);
[x, speedX] = scrollStep(x, frame.speed[0], frame.target[0], capX, accel, brake);
[y, speedY] = scrollStep(y, frame.speed[1], frame.target[1], capY, accel, brake);
frame.speed = [speedX, speedY];
frame.from = frame.to;
frame.to = [x, y];
}
const t = Math.min(frame.progress, 1);
let [x, y] = frame.from.map((from, axis) => Math.round(from + (frame.to[axis] - from) * t));
const [atX, atY] = NativeUI.scrollTo(frame.handle, x, y);
deferImageFetches(IMAGE_DEFER_MS);
// The engine stores offsets as floats. Far from the request means clamped by a content size that
// changed since the target was chosen: settle where it stopped.
const clampX = Math.abs(atX - x) > 0.5, clampY = Math.abs(atY - y) > 0.5;
if (clampX) frame.target[0] = x = Math.round(atX);
if (clampY) frame.target[1] = y = Math.round(atY);
if (clampX || clampY) [frame.from, frame.to, frame.progress] = [[x, y], [x, y], 1];
frame.position = [x, y];
if (x !== frame.target[0] || y !== frame.target[1]) return;
frame.stop();
const clamped = x !== frame.x || y !== frame.y;
frame.x = x;
frame.y = y;
frame.notify();
if (clamped) frame.props.onScrollTarget?.({x, y});
});
},
/**
* The content moved up by `dy` (a VirtualList dropping rows above the viewport, or down for a
* negative `dy`): scrolls by the same amount so nothing moves on screen, mid-animation included.
*/
shift(dy) {
const [, atY] = NativeUI.scrollTo(frame.handle, NaN, NaN);
NativeUI.scrollTo(frame.handle, NaN, atY - dy);
frame.y -= dy;
if (frame.timer === null) return;
frame.from = [frame.from[0], frame.from[1] - dy];
frame.to = [frame.to[0], frame.to[1] - dy];
frame.position = [frame.position[0], frame.position[1] - dy];
frame.target[1] -= dy;
},
// Viewport or offset changed; while animating, y is the target, so listeners see where it is going.
notify() {
for (const listener of frame.listeners) listener();
},
stop() {
frame.timer?.();
frame.timer = null;
},
onLayout(event) {
frame.viewport = event.layout;
frame.notify();
frame.props.onLayout?.(event);
},
// While focus scrolling animates, x/y already hold its target; touch scrolling updates them live.
onScroll(event) {
if (frame.timer === null) {
frame.x = event.scrollX;
frame.y = event.scrollY;
frame.notify();
}
frame.props.onScroll?.(event);
},
ref(handle) {
frame.handle = handle;
assignRef(frame.forwardedRef, handle);
},
};
return frame;
}