Angular virtual scroll strategy for different fixed-size items
Asked Answered
C

2

12

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.

Cholon answered 13/2, 2021 at 8:14 Comment(0)
C
17

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.

Cameroncameroon answered 14/2, 2021 at 6:58 Comment(0)
R
0

I just provided a shorter solution for this issue in this thread which works perfectly with Angular 18.

https://mcmap.net/q/581057/-cdk-virtual-scroll-viewport-with-variable-item-heights

Also those who want to try @angular/cdk-experimental with autosize, unfortunately, didn't work for me with Angular 18 either.

All steps are provided and tested.

Rehnberg answered 18/7 at 13:31 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.