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'; 16import * as Geometry 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: Geometry.Rect) => void; 25} 26 27export interface Data { 28 opts: VirtualScrollHelperOpts; 29 rect?: Geometry.Rect; 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 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 = containerElement.getBoundingClientRect(); 91 92 // Expand the viewportRect by the tolerance 93 const viewportExpandedRect = Geometry.expandRect(viewportRect, tolerancePx); 94 95 const sliderClientRect = sliderElement.getBoundingClientRect(); 96 const viewportClamped = Geometry.intersectRects( 97 viewportExpandedRect, 98 sliderClientRect, 99 ); 100 101 // Translate the puck rect into client space (currently in slider space) 102 const puckClientRect = Geometry.translateRect(data.rect, { 103 x: sliderClientRect.x, 104 y: sliderClientRect.y, 105 }); 106 107 // Check if the tolerance rect entirely contains the expanded viewport rect 108 // If not, request an update 109 if (!Geometry.containsRect(puckClientRect, viewportClamped)) { 110 const targetPuckRect = getTargetPuckRect( 111 sliderElement, 112 containerElement, 113 overdrawPx, 114 ); 115 callback(targetPuckRect); 116 data.rect = targetPuckRect; 117 } 118 } 119} 120 121// Returns what the puck rect should look like 122function getTargetPuckRect( 123 sliderElement: HTMLElement, 124 containerElement: Element, 125 overdrawPx: number, 126) { 127 const sliderElementRect = sliderElement.getBoundingClientRect(); 128 const containerRect = containerElement.getBoundingClientRect(); 129 130 // Calculate the intersection of the container's viewport and the target 131 const intersection = Geometry.intersectRects( 132 containerRect, 133 sliderElementRect, 134 ); 135 136 // Pad the intersection by the overdraw amount 137 const intersectionExpanded = Geometry.expandRect(intersection, overdrawPx); 138 139 // Intersect with the original target rect unless we want to avoid resizes 140 const targetRect = Geometry.intersectRects( 141 intersectionExpanded, 142 sliderElementRect, 143 ); 144 145 return Geometry.rebaseRect( 146 targetRect, 147 sliderElementRect.x, 148 sliderElementRect.y, 149 ); 150} 151