xref: /aosp_15_r20/development/tools/winscope/src/viewers/common/variable_height_scroll_strategy.ts (revision 90c8c64db3049935a07c6143d7fd006e26f8ecca)
1/*
2 * Copyright 2023, The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *     http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17import {
18  CdkVirtualScrollViewport,
19  VirtualScrollStrategy,
20} from '@angular/cdk/scrolling';
21import {distinctUntilChanged, Observable, Subject} from 'rxjs';
22
23export abstract class VariableHeightScrollStrategy
24  implements VirtualScrollStrategy
25{
26  static readonly HIDDEN_ELEMENTS_TO_RENDER = 20;
27  private scrollItems: object[] = [];
28  private itemHeightCache = new Map<number, ItemHeight>(); // indexed by scrollIndex
29  private wrapper: any = undefined;
30  private viewport: CdkVirtualScrollViewport | undefined;
31  scrolledIndexChangeSubject = new Subject<number>();
32  scrolledIndexChange: Observable<number> =
33    this.scrolledIndexChangeSubject.pipe(distinctUntilChanged());
34
35  attach(viewport: CdkVirtualScrollViewport) {
36    this.viewport = viewport;
37    this.wrapper = viewport.getElementRef().nativeElement.childNodes[0];
38    if (this.scrollItems.length > 0) {
39      this.viewport.setTotalContentSize(this.getTotalItemsHeight());
40      this.updateRenderedRange();
41    }
42  }
43
44  detach() {
45    this.viewport = undefined;
46    this.wrapper = undefined;
47  }
48
49  onDataLengthChanged() {
50    if (!this.viewport) {
51      return;
52    }
53    this.viewport.setTotalContentSize(this.getTotalItemsHeight());
54    this.updateRenderedRange();
55  }
56
57  onContentScrolled(): void {
58    if (this.viewport) {
59      this.updateRenderedRange();
60    }
61  }
62
63  onContentRendered() {
64    // do nothing
65  }
66
67  onRenderedOffsetChanged() {
68    // do nothing
69  }
70
71  updateItems(items: object[]) {
72    this.scrollItems = items;
73
74    if (this.viewport) {
75      this.viewport.checkViewportSize();
76    }
77  }
78
79  scrollToIndex(index: number) {
80    if (!this.viewport) {
81      return;
82    }
83    // scroll previous index to top, so when previous index is partially rendered the target index is still fully rendered
84    const previousIndex = Math.max(0, index - 1);
85    const offset = this.getOffsetByItemIndex(previousIndex);
86    this.viewport.scrollToOffset(offset);
87  }
88
89  private updateRenderedRange() {
90    if (!this.viewport) {
91      return;
92    }
93
94    const scrollIndex = this.calculateIndexFromOffset(
95      this.viewport.measureScrollOffset(),
96    );
97    const range = {
98      start: Math.max(
99        0,
100        scrollIndex - VariableHeightScrollStrategy.HIDDEN_ELEMENTS_TO_RENDER,
101      ),
102      end: Math.min(
103        this.viewport.getDataLength(),
104        scrollIndex +
105          this.numberOfItemsInViewport(scrollIndex) +
106          VariableHeightScrollStrategy.HIDDEN_ELEMENTS_TO_RENDER,
107      ),
108    };
109    this.viewport.setRenderedRange(range);
110    this.viewport.setRenderedContentOffset(
111      this.getOffsetByItemIndex(range.start),
112    );
113    this.scrolledIndexChangeSubject.next(scrollIndex);
114
115    this.updateItemHeightCache();
116  }
117
118  private updateItemHeightCache() {
119    if (!this.wrapper || !this.viewport) {
120      return;
121    }
122
123    let cacheUpdated = false;
124
125    for (const node of this.wrapper.childNodes) {
126      if (node && node.nodeName === 'DIV') {
127        const id = Number(node.getAttribute('item-id'));
128        const cachedHeight = this.itemHeightCache.get(id);
129
130        if (
131          cachedHeight?.source !== ItemHeightSource.PREDICTED ||
132          cachedHeight.value !== node.clientHeight
133        ) {
134          this.itemHeightCache.set(id, {
135            value: node.clientHeight,
136            source: ItemHeightSource.RENDERED,
137          });
138          cacheUpdated = true;
139        }
140      }
141    }
142
143    if (cacheUpdated) {
144      this.viewport.setTotalContentSize(this.getTotalItemsHeight());
145    }
146  }
147
148  private getTotalItemsHeight(): number {
149    return this.getItemsHeight(this.scrollItems);
150  }
151
152  private getOffsetByItemIndex(index: number): number {
153    return this.getItemsHeight(this.scrollItems.slice(0, index));
154  }
155
156  private getItemsHeight(items: object[]): number {
157    return items
158      .map((item, index) => this.getItemHeight(item, index))
159      .reduce((prev, curr) => prev + curr, 0);
160  }
161
162  private calculateIndexFromOffset(offset: number): number {
163    return this.calculateIndexOfFinalRenderedItem(0, offset) ?? 0;
164  }
165
166  private numberOfItemsInViewport(start: number): number {
167    if (!this.viewport) {
168      return 0;
169    }
170
171    const viewportHeight = this.viewport.getViewportSize();
172    const i = this.calculateIndexOfFinalRenderedItem(start, viewportHeight);
173    return i ? i - start + 1 : 0;
174  }
175
176  private calculateIndexOfFinalRenderedItem(
177    start: number,
178    viewportHeight: number,
179  ): number | undefined {
180    let totalItemHeight = 0;
181    for (let i = start; i < this.scrollItems.length; i++) {
182      const item = this.scrollItems[i];
183      totalItemHeight += this.getItemHeight(item, i);
184
185      if (totalItemHeight >= viewportHeight) {
186        return i;
187      }
188    }
189    return undefined;
190  }
191
192  private getItemHeight(item: object, index: number): number {
193    const currentHeight = this.itemHeightCache.get(index);
194    if (!currentHeight) {
195      const predictedHeight = this.predictScrollItemHeight(item);
196      this.itemHeightCache.set(index, {
197        value: predictedHeight,
198        source: ItemHeightSource.PREDICTED,
199      });
200      return predictedHeight;
201    } else {
202      return currentHeight.value;
203    }
204  }
205
206  protected subItemHeight(subItem: string, rowLength: number): number {
207    return Math.ceil(subItem.length / rowLength) * this.defaultRowSize;
208  }
209
210  protected abstract readonly defaultRowSize: number;
211
212  // best-effort estimate of item height using hardcoded values -
213  // we render more items than are in the viewport, and once rendered,
214  // the item's actual height is cached and used instead of the estimate
215  protected abstract predictScrollItemHeight(entry: object): number;
216}
217
218enum ItemHeightSource {
219  PREDICTED,
220  RENDERED,
221}
222
223interface ItemHeight {
224  value: number;
225  source: ItemHeightSource;
226}
227