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
84    const offset = this.getOffsetByItemIndex(index);
85    this.viewport.scrollToOffset(offset);
86  }
87
88  private updateRenderedRange() {
89    if (!this.viewport) {
90      return;
91    }
92
93    const scrollIndex = this.calculateIndexFromOffset(
94      this.viewport.measureScrollOffset(),
95    );
96    const range = {
97      start: Math.max(
98        0,
99        scrollIndex - VariableHeightScrollStrategy.HIDDEN_ELEMENTS_TO_RENDER,
100      ),
101      end: Math.min(
102        this.viewport.getDataLength(),
103        scrollIndex +
104          this.numberOfItemsInViewport(scrollIndex) +
105          VariableHeightScrollStrategy.HIDDEN_ELEMENTS_TO_RENDER,
106      ),
107    };
108    this.viewport.setRenderedRange(range);
109    this.viewport.setRenderedContentOffset(
110      this.getOffsetByItemIndex(range.start),
111    );
112    this.scrolledIndexChangeSubject.next(scrollIndex);
113
114    this.updateItemHeightCache();
115  }
116
117  private updateItemHeightCache() {
118    if (!this.wrapper || !this.viewport) {
119      return;
120    }
121
122    let cacheUpdated = false;
123
124    for (const node of this.wrapper.childNodes) {
125      if (node && node.nodeName === 'DIV') {
126        const id = Number(node.getAttribute('item-id'));
127        const cachedHeight = this.itemHeightCache.get(id);
128
129        if (
130          cachedHeight?.source !== ItemHeightSource.PREDICTED ||
131          cachedHeight.value !== node.clientHeight
132        ) {
133          this.itemHeightCache.set(id, {
134            value: node.clientHeight,
135            source: ItemHeightSource.RENDERED,
136          });
137          cacheUpdated = true;
138        }
139      }
140    }
141
142    if (cacheUpdated) {
143      this.viewport.setTotalContentSize(this.getTotalItemsHeight());
144    }
145  }
146
147  private getTotalItemsHeight(): number {
148    return this.getItemsHeight(this.scrollItems);
149  }
150
151  private getOffsetByItemIndex(index: number): number {
152    return this.getItemsHeight(this.scrollItems.slice(0, index));
153  }
154
155  private getItemsHeight(items: object[]): number {
156    return items
157      .map((item, index) => this.getItemHeight(item, index))
158      .reduce((prev, curr) => prev + curr, 0);
159  }
160
161  private calculateIndexFromOffset(offset: number): number {
162    return this.calculateIndexOfFinalRenderedItem(0, offset) ?? 0;
163  }
164
165  private numberOfItemsInViewport(start: number): number {
166    if (!this.viewport) {
167      return 0;
168    }
169
170    const viewportHeight = this.viewport.getViewportSize();
171    const i = this.calculateIndexOfFinalRenderedItem(start, viewportHeight);
172    return i ? i - start + 1 : 0;
173  }
174
175  private calculateIndexOfFinalRenderedItem(
176    start: number,
177    viewportHeight: number,
178  ): number | undefined {
179    let totalItemHeight = 0;
180    for (let i = start; i < this.scrollItems.length; i++) {
181      const item = this.scrollItems[i];
182      totalItemHeight += this.getItemHeight(item, i);
183
184      if (totalItemHeight >= viewportHeight) {
185        return i;
186      }
187    }
188    return undefined;
189  }
190
191  private getItemHeight(item: object, index: number): number {
192    const currentHeight = this.itemHeightCache.get(index);
193    if (!currentHeight) {
194      const predictedHeight = this.predictScrollItemHeight(item);
195      this.itemHeightCache.set(index, {
196        value: predictedHeight,
197        source: ItemHeightSource.PREDICTED,
198      });
199      return predictedHeight;
200    } else {
201      return currentHeight.value;
202    }
203  }
204
205  protected subItemHeight(subItem: string, rowLength: number): number {
206    return Math.ceil(subItem.length / rowLength) * this.defaultRowSize;
207  }
208
209  protected abstract readonly defaultRowSize: number;
210
211  // best-effort estimate of item height using hardcoded values -
212  // we render more items than are in the viewport, and once rendered,
213  // the item's actual height is cached and used instead of the estimate
214  protected abstract predictScrollItemHeight(entry: object): number;
215}
216
217enum ItemHeightSource {
218  PREDICTED,
219  RENDERED,
220}
221
222interface ItemHeight {
223  value: number;
224  source: ItemHeightSource;
225}
226