Angular cdkVirtualFor
allows to provide a custom virtual scroll strategy, this is how fixed and auto height strategies you mention are implemented. In your case it would accept item height array as an input. I had to deal with this exact case recently: a list form to which user can add any number of items and item size can be calculated, the custom virtual scroll strategy was used to improve performance. To understand the inner workings of virtual scrolling strategies, I found it really helpful to dive into source code of fixed and auto size strategies and this article by Alex Inkin.
Here's how such a strategy might look. This is basically a simplified fixed height strategy, but with height calculations instead of fixed height value.
class CustomVirtualScrollStrategy implements VirtualScrollStrategy {
constructor(private itemHeights: ItemHeight) {}
private viewport?: CdkVirtualScrollViewport
private scrolledIndexChange$ = new Subject<number>()
public scrolledIndexChange: Observable<number> = this.scrolledIndexChange$.pipe(distinctUntilChanged())
_minBufferPx = 100
_maxBufferPx = 100
attach(viewport: CdkVirtualScrollViewport) {
this.viewport = viewport;
this.updateTotalContentSize()
this.updateRenderedRange()
}
detach() {
this.scrolledIndexChange$.complete()
delete this.viewport
}
public updateItemHeights(itemHeights: ItemHeight) {
this.itemHeights = itemHeights
this.updateTotalContentSize()
this.updateRenderedRange()
}
private getItemOffset(index: number): number {
return this.itemHeights.slice(0, index).reduce((acc, itemHeight) => acc + itemHeight, 0)
}
private getTotalContentSize(): number {
return this.itemHeights.reduce((a,b)=>a+b, 0)
}
private getListRangeAt(scrollOffset: number, viewportSize: number): ListRange {
type Acc = {itemIndexesInRange: number[], currentOffset: number}
const visibleOffsetRange: Range = [scrollOffset, scrollOffset + viewportSize]
const itemsInRange = this.itemHeights.reduce<Acc>((acc, itemHeight, index) => {
const itemOffsetRange: Range = [acc.currentOffset, acc.currentOffset + itemHeight]
return {
currentOffset: acc.currentOffset + itemHeight,
itemIndexesInRange: intersects(itemOffsetRange, visibleOffsetRange)
? [...acc.itemIndexesInRange, index]
: acc.itemIndexesInRange
}
}, {itemIndexesInRange: [], currentOffset: 0}).itemIndexesInRange
const BUFFER_BEFORE = 5
const BUFFER_AFTER = 5
return {
start: clamp(0, (itemsInRange[0] ?? 0) - BUFFER_BEFORE, this.itemHeights.length - 1),
end: clamp(0, (last(itemsInRange) ?? 0) + BUFFER_AFTER, this.itemHeights.length)
}
}
private updateRenderedRange() {
if (!this.viewport) return
const viewportSize = this.viewport.getViewportSize();
const scrollOffset = this.viewport.measureScrollOffset();
const newRange = this.getListRangeAt(scrollOffset, viewportSize)
const oldRange = this.viewport?.getRenderedRange()
if (isEqual(newRange, oldRange)) return
this.viewport.setRenderedRange(newRange);
this.viewport.setRenderedContentOffset(this.getItemOffset(newRange.start));
this.scrolledIndexChange$.next(newRange.start);
}
private updateTotalContentSize() {
const contentSize = this.getTotalContentSize()
console.log(contentSize)
this.viewport?.setTotalContentSize(contentSize)
}
onContentScrolled() {
this.updateRenderedRange()
}
onDataLengthChanged() {
this.updateTotalContentSize()
this.updateRenderedRange()
}
onContentRendered() {}
onRenderedOffsetChanged() {}
scrollToIndex(index: number, behavior: ScrollBehavior) {
this.viewport?.scrollToOffset(this.getItemOffset(index), behavior)
}
}
See this Stackblitz for a full, working implementation.