Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Angular virtual scroll strategy for different fixed-size items

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.

like image 846
Milan Milanov Avatar asked Feb 13 '21 08:02

Milan Milanov


1 Answers

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.

like image 179
Klaster_1 Avatar answered Nov 14 '22 04:11

Klaster_1