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