Writing custom virtual scroll strategy in Angular apps
Learn how you can handle dynamic items size for Angular CDK virtual scroll by providing your own VirtualScrollStrategy.

Writing custom virtual scroll strategy in Angular apps
Learn how you can handle dynamic items size for Angular CDK virtual scroll by providing your own VirtualScrollStrategy.


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.


Size calculationsLink to this section
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:
<>Copyfunction getCycle(label: number, week: number): readonly number[][] { return Array.from({length: 28}, (_, i) => Array.from( {length: 12}, (_, month) => label + weekCount(i, month) * week, ), ); }
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:
<>Copyfunction 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
<>Copyfunction 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, ); }
VirtualScrollStrategy
We can provide our strategy to virtual scroll via VIRTUAL_SCROLL_STRATEGY
token:
<>Copy{ provide: VIRTUAL_SCROLL_STRATEGY, useClass: MobileCalendarStrategy, },
Our class needs to implement VirtualScrollStrategy
interface:
<>Copyexport 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:
<>Copyexport 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:
<>Copyprivate 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:
<>Copyprivate 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:
<>Copyprivate 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:
<>Copy<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.
<>Copyconst 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 nativeScrollBehavior
functionality. It is still not implemented in Safari in 2k19. You can fix it with your own smooth scroll insidescrollToIndex
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. You can 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.
Comments (0)
Be the first to leave a comment
About the author

I’m a devoted Angular developer and a musician. I use one thing to help me not to starve while doing another and I love doing both. I work on Taiga UI at Tinkoff and I like sharing my findings.

About the author
Alex Inkin
I’m a devoted Angular developer and a musician. I use one thing to help me not to starve while doing another and I love doing both. I work on Taiga UI at Tinkoff and I like sharing my findings.
About the author

I’m a devoted Angular developer and a musician. I use one thing to help me not to starve while doing another and I love doing both. I work on Taiga UI at Tinkoff and I like sharing my findings.