blob: 4fbe5c104bc964d75e81f73d46cc65fb0fc785af [file] [log] [blame]
// Copyright (C) 2024 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import {Trash} from '../base/disposable';
import * as Geometry from '../base/geom';
export interface VirtualScrollHelperOpts {
overdrawPx: number;
// How close we can get to undrawn regions before updating
tolerancePx: number;
callback: (r: Geometry.Rect) => void;
}
export interface Data {
opts: VirtualScrollHelperOpts;
rect?: Geometry.Rect;
}
export class VirtualScrollHelper {
private readonly _trash = new Trash();
private readonly _data: Data[] = [];
constructor(
sliderElement: HTMLElement,
containerElement: Element,
opts: VirtualScrollHelperOpts[] = [],
) {
this._data = opts.map((opts) => {
return {opts};
});
const recalculateRects = () => {
this._data.forEach((data) =>
recalculatePuckRect(sliderElement, containerElement, data),
);
};
containerElement.addEventListener('scroll', recalculateRects, {
passive: true,
});
this._trash.addCallback(() =>
containerElement.removeEventListener('scroll', recalculateRects),
);
// Resize observer callbacks are called once immediately
const resizeObserver = new ResizeObserver(() => {
recalculateRects();
});
resizeObserver.observe(containerElement);
resizeObserver.observe(sliderElement);
this._trash.addCallback(() => {
resizeObserver.disconnect();
});
}
dispose() {
this._trash.dispose();
}
}
function recalculatePuckRect(
sliderElement: HTMLElement,
containerElement: Element,
data: Data,
): void {
const {tolerancePx, overdrawPx, callback} = data.opts;
if (!data.rect) {
const targetPuckRect = getTargetPuckRect(
sliderElement,
containerElement,
overdrawPx,
);
callback(targetPuckRect);
data.rect = targetPuckRect;
} else {
const viewportRect = containerElement.getBoundingClientRect();
// Expand the viewportRect by the tolerance
const viewportExpandedRect = Geometry.expandRect(viewportRect, tolerancePx);
const sliderClientRect = sliderElement.getBoundingClientRect();
const viewportClamped = Geometry.intersectRects(
viewportExpandedRect,
sliderClientRect,
);
// Translate the puck rect into client space (currently in slider space)
const puckClientRect = Geometry.translateRect(data.rect, {
x: sliderClientRect.x,
y: sliderClientRect.y,
});
// Check if the tolerance rect entirely contains the expanded viewport rect
// If not, request an update
if (!Geometry.containsRect(puckClientRect, viewportClamped)) {
const targetPuckRect = getTargetPuckRect(
sliderElement,
containerElement,
overdrawPx,
);
callback(targetPuckRect);
data.rect = targetPuckRect;
}
}
}
// Returns what the puck rect should look like
function getTargetPuckRect(
sliderElement: HTMLElement,
containerElement: Element,
overdrawPx: number,
) {
const sliderElementRect = sliderElement.getBoundingClientRect();
const containerRect = containerElement.getBoundingClientRect();
// Calculate the intersection of the container's viewport and the target
const intersection = Geometry.intersectRects(
containerRect,
sliderElementRect,
);
// Pad the intersection by the overdraw amount
const intersectionExpanded = Geometry.expandRect(intersection, overdrawPx);
// Intersect with the original target rect unless we want to avoid resizes
const targetRect = Geometry.intersectRects(
intersectionExpanded,
sliderElementRect,
);
return Geometry.rebaseRect(
targetRect,
sliderElementRect.x,
sliderElementRect.y,
);
}