1// Copyright (C) 2024 The Android Open Source Project 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// http://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15import {DisposableStack} from '../base/disposable_stack'; 16import {Bounds2D, Rect2D} from '../base/geom'; 17 18export interface VirtualScrollHelperOpts { 19 overdrawPx: number; 20 21 // How close we can get to undrawn regions before updating 22 tolerancePx: number; 23 24 callback: (r: Rect2D) => void; 25} 26 27export interface Data { 28 opts: VirtualScrollHelperOpts; 29 rect?: Bounds2D; 30} 31 32export class VirtualScrollHelper { 33 private readonly _trash = new DisposableStack(); 34 private readonly _data: Data[] = []; 35 36 constructor( 37 sliderElement: HTMLElement, 38 containerElement: Element, 39 opts: VirtualScrollHelperOpts[] = [], 40 ) { 41 this._data = opts.map((opts) => { 42 return {opts}; 43 }); 44 45 const recalculateRects = () => { 46 this._data.forEach((data) => 47 recalculatePuckRect(sliderElement, containerElement, data), 48 ); 49 }; 50 51 containerElement.addEventListener('scroll', recalculateRects, { 52 passive: true, 53 }); 54 this._trash.defer(() => 55 containerElement.removeEventListener('scroll', recalculateRects), 56 ); 57 58 // Resize observer callbacks are called once immediately 59 const resizeObserver = new ResizeObserver(() => { 60 recalculateRects(); 61 }); 62 63 resizeObserver.observe(containerElement); 64 resizeObserver.observe(sliderElement); 65 this._trash.defer(() => { 66 resizeObserver.disconnect(); 67 }); 68 } 69 70 [Symbol.dispose]() { 71 this._trash.dispose(); 72 } 73} 74 75function recalculatePuckRect( 76 sliderElement: HTMLElement, 77 containerElement: Element, 78 data: Data, 79): void { 80 const {tolerancePx, overdrawPx, callback} = data.opts; 81 if (!data.rect) { 82 const targetPuckRect = getTargetPuckRect( 83 sliderElement, 84 containerElement, 85 overdrawPx, 86 ); 87 callback(targetPuckRect); 88 data.rect = targetPuckRect; 89 } else { 90 const viewportRect = new Rect2D(containerElement.getBoundingClientRect()); 91 92 // Expand the viewportRect by the tolerance 93 const viewportExpandedRect = viewportRect.expand(tolerancePx); 94 95 const sliderClientRect = sliderElement.getBoundingClientRect(); 96 const viewportClamped = viewportExpandedRect.intersect(sliderClientRect); 97 98 // Translate the puck rect into client space (currently in slider space) 99 const puckClientRect = viewportClamped.translate({ 100 x: sliderClientRect.x, 101 y: sliderClientRect.y, 102 }); 103 104 // Check if the tolerance rect entirely contains the expanded viewport rect 105 // If not, request an update 106 if (!puckClientRect.contains(viewportClamped)) { 107 const targetPuckRect = getTargetPuckRect( 108 sliderElement, 109 containerElement, 110 overdrawPx, 111 ); 112 callback(targetPuckRect); 113 data.rect = targetPuckRect; 114 } 115 } 116} 117 118// Returns what the puck rect should look like 119function getTargetPuckRect( 120 sliderElement: HTMLElement, 121 containerElement: Element, 122 overdrawPx: number, 123) { 124 const sliderElementRect = sliderElement.getBoundingClientRect(); 125 const containerRect = new Rect2D(containerElement.getBoundingClientRect()); 126 127 // Calculate the intersection of the container's viewport and the target 128 const intersection = containerRect.intersect(sliderElementRect); 129 130 // Pad the intersection by the overdraw amount 131 const intersectionExpanded = intersection.expand(overdrawPx); 132 133 // Intersect with the original target rect unless we want to avoid resizes 134 const targetRect = intersectionExpanded.intersect(sliderElementRect); 135 136 return targetRect.reframe(sliderElementRect); 137} 138