xref: /aosp_15_r20/external/perfetto/ui/src/widgets/virtual_scroll_helper.ts (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
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