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