Angular CDK has virtual scroll toolkit starting from version 7.

It works great straight out of the box when your items all have the same size. We just tell cdk-virtual-scroll-viewport size of our item and that’s it. We can then tell it to scroll to an item, do that smoothly or subscribe to current item index when user scrolls it. What do we do though, if our item size can change? This is the case for custom virtual scroll strategy. We can create it to teach viewport how to work with our items.

In my case I needed to create a mobile view for calendar. You should be able to scroll months seamlessly. In the meantime, each month can have different height due to different number of weeks it takes. Let’s try to figure out what virtual scroll strategy is and write our own.

Mobile calendar in action (in Russia we start the week on Monday)

Size calculations

Calendar repeats itself every 28 years. This is true when you do not take missing leap years into account. Every 100 years leap year is skipped, unless year is dividable by 400. In our case we do not need years before 1900 and after 2100 so we’re fine.

We will begin at 1905 and to have 7 full cycles our calendar would span 196 years. We will miss by one week in February 2100 but that’s the very end of the whole thing so that wouldn’t matter.

All calculations are performed during scroll, so they must be fast. To be able to compute everything on the fly let’s prepare a cycle constant. It will consist of 28 arrays of 12 numbers, storing sizes for each month:

function getCycle(label: number, week: number): readonly number[][] {
   return Array.from({length: 28}, (_, i) =>
       Array.from(
           {length: 12},
           (_, month) => label + weekCount(i, month) * week,
       ),
   );
}
We will store the result in const CYCLE = getCycle(64, 48);

This function takes month label height and week height as inputs (64 and 48 pixels for the gif above). We can calculate weeks count in a month by this simple function:

function weekCount(year: number, month: number): number {
   const firstOfMonth = new Date(year + STARTING_YEAR, month, 1);
   const lastOfMonth = new Date(year + STARTING_YEAR, month + 1, 0);
   const days = firstOfMonth.getDay() + lastOfMonth.getDate();

   return Math.ceil(days / 7);
}
We would also need a way to get height by the year & month inside the cycle
function reduceCycle(lastYear: number = 28, lastMonth: number = 12): number {
   return CYCLE.reduce(
       (total, year, yearIndex) =>
           yearIndex <= lastYear
               ? total +
                 year.reduce(
                     (sum, month, monthIndex) =>
                         yearIndex < lastYear ||
                         (yearIndex === lastYear && monthIndex < lastMonth)
                             ? sum + month
                             : sum,
                     0,
                 )
               : total,
       0,
   );
}
We will call it without arguments the first time to get height of a full cycle

VirtualScrollStrategy

We can provide our strategy to virtual scroll via VIRTUAL_SCROLL_STRATEGY token:

{
   provide: VIRTUAL_SCROLL_STRATEGY,
   useClass: MobileCalendarStrategy,
},

Our class needs to implement VirtualScrollStrategy interface:

export interface VirtualScrollStrategy {
   scrolledIndexChange: Observable<number>;
   attach(viewport: CdkVirtualScrollViewport): void;
   detach(): void;
   onContentScrolled(): void;
   onDataLengthChanged(): void;
   onContentRendered(): void;
   onRenderedOffsetChanged(): void;
   scrollToIndex(index: number, behavior: ScrollBehavior): void;
}

attach and detach are responsible for initialization and destruction. onContentScrolled is the most important method for us. Every time user scrolls the container viewport calls this function. Angular CDK virtual scroll uses requestAnimationFrame to debounce callbacks. It means that this method is called once per frame at most.

Virtual scroll would call onDataLengthChanged when elements we iterate over change. This is not going to happen in our case but if this is a possibility — code here would look a lot like attach method. We would need to recalculate total height and currently displayed items.

onContentRendered and onRenderedOffsetChanged are called by CdkVirtualScrollViewport. It happens when you manually set new rendered items range or new offset. Our calendar doesn’t need that. In case your strategy requires it — these methods are pretty straightforward. Calculate new offset in onContentRendered. Do the opposite in onRenderedOffsetChanged — get new rendered range for changed offset.

And another important method for us — scrollToIndex. It scrolls container to given element. There’s also the opposite — scrolledIndexChange. It tracks first currently visible item.

Let’s create the easy methods first and then dive into the meat of the thing:

export class MobileCalendarStrategy implements VirtualScrollStrategy {
   private index$ = new Subject<number>();

   private viewport: CdkVirtualScrollViewport | null = null;

   scrolledIndexChange = this.index$.pipe(distinctUntilChanged());

   attach(viewport: CdkVirtualScrollViewport) {
       this.viewport = viewport;
       this.viewport.setTotalContentSize(CYCLE_HEIGHT * 7);
       this.updateRenderedRange(this.viewport);
   }

   detach() {
       this.index$.complete();
       this.viewport = null;
   }

   onContentScrolled() {
       if (this.viewport) {
           this.updateRenderedRange(this.viewport);
       }
   }

   scrollToIndex(index: number, behavior: ScrollBehavior): void {
       if (this.viewport) {
           this.viewport.scrollToOffset(this.getOffsetForIndex(index), behavior);
       }
   }
  
   // ...
}

We need to be able to get index of the element by offset and the opposite — offset by given index. For the first task we can use reduceCycle function we wrote:

private getOffsetForIndex(index: number): number {
   const month = index % 12;
   const year = (index - month) / 12;

   return this.computeHeight(year, month);
}

private computeHeight(year: number, month: number): number {   
   const remainder = year % 28;
   const remainderHeight = reduceCycle(remainder, month);
   const fullCycles = (year - remainder) / 28;
   const fullCyclesHeight = fullCycles * CYCLE_HEIGHT;

   return fullCyclesHeight + remainderHeight;
}

To get height of all items before current we first count how many whole 28 years cycles are there. Next we will reduce a cycle up to the given month. The index for offset operation is a bit more complex:

private getIndexForOffset(offset: number): number {
   const remainder = offset % CYCLE_HEIGHT;
   const years = ((offset - remainder) / CYCLE_HEIGHT) * 28;

   let accumulator = 0;

   for (let year = 0; year < CYCLE.length; year++) {
      for (let month = 0; month < CYCLE[year].length; month++) {
          accumulator += CYCLE[year][month];

          if (accumulator - CYCLE[year][month] / 2 > remainder) {
              return Math.max((years + year) * MONTHS_IN_YEAR + month, 0);
          }
      }
   }


   return 196;
}

First we get total height of all full 28 years cycles that could fit in given offset. Then we start iterating over cycle array. We add all heights up until it surpasses the offset we are looking for. We also need to compare half of the months height when we add (CYCLE[year][month] / 2). This would give us not just the topmost visible item but the closest one to the visible boundary. It allows us to align it with the viewport when scrolling stops.

All that’s left is to write the main function, responsible for drawing visible subset of items:

private updateRenderedRange(viewport: CdkVirtualScrollViewport) {
   const viewportSize = viewport.getViewportSize();
   const offset = viewport.measureScrollOffset();
   const {start, end} = viewport.getRenderedRange();
   const dataLength = viewport.getDataLength();
   const newRange = {start, end};
   const firstVisibleIndex = this.getIndexForOffset(offset);
   const startBuffer = offset - this.getOffsetForIndex(start);

   if (startBuffer < BUFFER && start !== 0) {
       newRange.start = Math.max(0, this.getIndexForOffset(offset - BUFFER * 2));
       newRange.end = Math.min(
           dataLength,
           this.getIndexForOffset(offset + viewportSize + BUFFER),
       );
   } else {
       const endBuffer = this.getOffsetForIndex(end) - offset - viewportSize;

       if (endBuffer < BUFFER && end !== dataLength) {
           newRange.start = Math.max(0, this.getIndexForOffset(offset - BUFFER));
           newRange.end = Math.min(
               dataLength,
               this.getIndexForOffset(offset + viewportSize + BUFFER * 2),
           );
       }
   }

   viewport.setRenderedRange(newRange);
   viewport.setRenderedContentOffset(this.getOffsetForIndex(newRange.start));
   this.index$.next(firstVisibleIndex);
}

Let’s go over it step by step. We get container size, current offset and visible items range and total number of items. Then we will find first visible item and offset for the first rendered element. After we know all this, we need to figure out, how we should alter rendered range and offset. Our scroll must seamlessly load new items and not waggle when we scroll and height changes. We will have a BUFFER constant to determine how far outside of the visible area we still render elements. Empirical approach shows that 500px is enough for this case. If distance to the first item becomes smaller than that — we add more items to cover double the buffer size. We also need to correct the other end of rendered range. Since we scroll in the opposite direction, single buffer would be enough. Same thing works for both scroll directions. Then we set new range and compute offset of its first element. Last thing to do is to emit current element index.

Usage

Our strategy is ready. Let’s add it to providers and use virtual scroll in the component’s template:

<cdk-virtual-scroll-viewport
   (scrolledIndexChange)="activeMonth = $event"
>
   <section
       *cdkVirtualFor="let month of months; templateCacheSize: 10"
   >
       <h1>{{month.name}}</h2>
       <our-calendar [month]="month"></our-calendar>
   </section>
</cdk-virtual-scroll-viewport>

The most difficult remaining task is smooth alignment to the closest month. Scroll on mobile devices may continue after finger leaves the surface. So it’s not that easy to figure out the moment when we should align visible month to top. To do so we will use RxJs. We subscribe to touchstart event, followed by touchend. Then we will use race operator to tell if scroll keeps going or if the touch ended without acceleration. If another scroll event did not emit during debounce time we perform the alignment. Otherwise we wait until the remaining scroll is finished. We also need takeUntil(touchstart$) because new touchstart event stops inertial scroll. We need our stream to return to the beginning in that case.

const touchstart$ = touchStartFrom(monthsScrollRef.elementRef.nativeElement);
const touchend$ = touchEndFrom(monthsScrollRef.elementRef.nativeElement);

// Smooth scroll to closest month after scrolling is done
touchstart$
   .pipe(
       switchMap(() => touchend$),
       switchMap(() =>
           race<unknown>(
               monthsScrollRef.elementScrolled(),
               timer(SCROLL_DEBOUNCE_TIME),
           ).pipe(
               debounceTime(SCROLL_DEBOUNCE_TIME * 2),
               take(1),
               takeUntil(touchstart$),
           ),
       ),
   )
   .subscribe(() => {
       monthsScrollRef.scrollToIndex(this.activeMonth, 'smooth');
   });
Worth mentioning that Angular CDK uses native ScrollBehavior functionality. It is still not implemented in Safari in 2k19. You can fix it with your own smooth scroll inside scrollToIndex method of your strategy.

Here’s a link to the demo, make sure you open it on a mobile device or enable mobile phone emulation in DevTools so that touch events work:

https://angular-virtual-scroll-strategy.stackblitz.io/

You peak at the code here.

Takeaway

Thanks to DI and Angular team farsight we were able to configure scroll to our needs. Virtual scroll over elements with variable heights seems a difficult task at first. But, if there’s a way to tell size of each element writing our own strategy has proven to be not that complex. You need to keep in mind that computations must be fast because they will be very frequent. You might need to show a lot of cards that could have or not have elements that affect the height . Come up with an efficient algorithm for height calculation and don’t be shy and write your own strategy.