• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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