I'm displaying an infinite, virtual scroll using Angular's cdk-virtual-scroll-viewport
. The functionality doesn't rely on anything special from it, besides subscribing for the view position, in order to load new elements when the user scrolls to the bottom (in a custom DataSource<Item>
):
connect(collectionViewer: CollectionViewer): Observable<Item[]> {
this.viewChanges = collectionViewer.viewChange.subscribe((listRange) => {
this.loadItemsFor(listRange);
});
..
}
That works fine when all the items have the same height (specified both in css and in the itemSize
of the <cdk-virtual-scroll-viewport>
. Now I'm trying to add a different type of item, which is of different size (lets say 100px
vs 50px
). This doesn't work well with the FixedSizeVirtualScrollStrategy, so I tried with autosize
from the cdl-experimental
(which uses AutoSizeVirtualScrollStrategy). However, with the dynamic strategy there's flickering of the scroll position once new elements are added to the datasource that backs up the virtual scroll (I assume because of the ItemAverager
).
Is there a feasible way to implement a mix between the two strategies? I know the type of each item in the list, and therefore it's height, so it should be possible to have exact calculations about what's being shown and what is to be loaded? It could potentially be not so performant with large collections, of course.
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.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With