• 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';
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