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'; 17import {scheduleFullRedraw} from './raf'; 18 19export interface VirtualScrollHelperOpts { 20 overdrawPx: number; 21 22 // How close we can get to undrawn regions before updating 23 tolerancePx: number; 24 25 callback: (r: Rect2D) => void; 26} 27 28export interface Data { 29 opts: VirtualScrollHelperOpts; 30 rect?: Bounds2D; 31} 32 33export class VirtualScrollHelper { 34 private readonly _trash = new DisposableStack(); 35 private readonly _data: Data[] = []; 36 37 constructor( 38 sliderElement: HTMLElement, 39 containerElement: Element, 40 opts: VirtualScrollHelperOpts[] = [], 41 ) { 42 this._data = opts.map((opts) => { 43 return {opts}; 44 }); 45 46 const recalculateRects = () => { 47 this._data.forEach((data) => 48 recalculatePuckRect(sliderElement, containerElement, data), 49 ); 50 scheduleFullRedraw('force'); 51 }; 52 53 containerElement.addEventListener('scroll', recalculateRects, { 54 passive: true, 55 }); 56 this._trash.defer(() => 57 containerElement.removeEventListener('scroll', recalculateRects), 58 ); 59 60 // Resize observer callbacks are called once immediately 61 const resizeObserver = new ResizeObserver(() => { 62 recalculateRects(); 63 }); 64 65 resizeObserver.observe(containerElement); 66 resizeObserver.observe(sliderElement); 67 this._trash.defer(() => { 68 resizeObserver.disconnect(); 69 }); 70 } 71 72 [Symbol.dispose]() { 73 this._trash.dispose(); 74 } 75} 76 77function recalculatePuckRect( 78 sliderElement: HTMLElement, 79 containerElement: Element, 80 data: Data, 81): void { 82 const {tolerancePx, overdrawPx, callback} = data.opts; 83 if (!data.rect) { 84 const targetPuckRect = getTargetPuckRect( 85 sliderElement, 86 containerElement, 87 overdrawPx, 88 ); 89 callback(targetPuckRect); 90 data.rect = targetPuckRect; 91 } else { 92 const viewportRect = new Rect2D(containerElement.getBoundingClientRect()); 93 94 // Expand the viewportRect by the tolerance 95 const viewportExpandedRect = viewportRect.expand(tolerancePx); 96 97 const sliderClientRect = sliderElement.getBoundingClientRect(); 98 const viewportClamped = viewportExpandedRect.intersect(sliderClientRect); 99 100 // Translate the puck rect into client space (currently in slider space) 101 const puckClientRect = viewportClamped.translate({ 102 x: sliderClientRect.x, 103 y: sliderClientRect.y, 104 }); 105 106 // Check if the tolerance rect entirely contains the expanded viewport rect 107 // If not, request an update 108 if (!puckClientRect.contains(viewportClamped)) { 109 const targetPuckRect = getTargetPuckRect( 110 sliderElement, 111 containerElement, 112 overdrawPx, 113 ); 114 callback(targetPuckRect); 115 data.rect = targetPuckRect; 116 } 117 } 118} 119 120// Returns what the puck rect should look like 121function getTargetPuckRect( 122 sliderElement: HTMLElement, 123 containerElement: Element, 124 overdrawPx: number, 125) { 126 const sliderElementRect = sliderElement.getBoundingClientRect(); 127 const containerRect = new Rect2D(containerElement.getBoundingClientRect()); 128 129 // Calculate the intersection of the container's viewport and the target 130 const intersection = containerRect.intersect(sliderElementRect); 131 132 // Pad the intersection by the overdraw amount 133 const intersectionExpanded = intersection.expand(overdrawPx); 134 135 // Intersect with the original target rect unless we want to avoid resizes 136 const targetRect = intersectionExpanded.intersect(sliderElementRect); 137 138 return targetRect.reframe(sliderElementRect); 139} 140