Virtual scrolling of content with variable height with Angular

georgii

Georgi Serev

Posted on May 8, 2023

Virtual scrolling of content with variable height with Angular

It has taken you precious time to develop your carefully crafted list of items with variable height with Angular. Now, your only remaining task is to add virtual scroll support. You install and integrate @angular/cdk/scrolling, but then you reach a dead end – Angular CDK virtual scroll can only handle items with fixed size. After some searching, you've came across this article. Hopefully, by the end of it, you should have a flawlessly-working list that supports virtual scrolling.

Harnessing @angular/cdk

Obviously, we are not going to reinvent the wheel by implementing our own virtual scroll. Surely, that's an option, but we would like to use the existing tools that have proven to work and Angular CDK actually offers a very capable instruments for that. What we are going to do is to implement a custom VirtualScrollStrategy. But before we start ...

@angular/cdk-experimental

It's worth mentioning that the CDK team is currently developing an autosize strategy which is part of the experimental version of the Angular CDK. You might achieve very good results with it but be aware that the strategy is not ready for production yet (official documentation) as of the time of writing this article. So, you might potentially experience unexpected behavior. In short, you could give it a try, in case it achieves the results you desire. Anyhow, without further ado, let's move our focus to the custom strategy.

Chapters

Since I thought that it makes logical sense, I separated the article in several chapters. If I have to summarize, we are going to:

  • Present our problem
  • Explain what a VirtualScrollStrategy is and how to harness it
  • Employ the strategy
  • Focus on the implementation of the internals

We'll try to skim throught some of these matters as quickly as possible, as granularity might not be important, but having a grasp of them would be essential for you to understand how things stitch together in the final product. The actual strategy implementation is going to be examined in the final chapter as the list suggests.

⚠️ NOTE: By just having a quick glance at the chapters and blindly copying the code examples, you might end up with a non-functional solution. If you are short on time and don't plan reading the text, you can head to the Final code section and explore the repository directly (the code is documented).

For the sake of convenience, here is a handy content:

Contents

I. Case study – Hero Feed

It is very likely that, if you develop Angular apps, you have came across the Hero tutorials in the official documentation. So, in order to be inline with that, I am going to build a Hero feed. In essence, it's just a hypothetical feed with randomly generated data that is trying to mimic a real-world example.

For that purpose, let's introduce the HeroMessage interface. The message will represent our list item:



export interface HeroMessage {
  id: string;
  name: string;
  date: Date;
  text: string;
  tags: string[];
}


Enter fullscreen mode Exit fullscreen mode

... and this is how it will look in the app:

HeroMessage Item

Respectively, I am going to create a component that renders that data which I'll name HeroMessageComponent. For the sake of saving time, I am going to omit the details as they are not very relevant to our main goal.

💾: You can check the full component code at GitHub

Now, let's move on to the strategy interface.

II. VirtualScrollStrategy – What is that?

VirtualScrollStrategy represents an interface that we should implement in order to describe our desired scrolling behavior, or more specifically, define which items should be rendered in the viewport. Actually, if you've noticed, the standard cdk-virtual-scroll-viewport component, as already mentioned, works with fixed-size items, the experimental CDK – variable-size items. These two modes are separate VirtualScrollStrategy-ies – FixedSize and Autosize, respectively. Let's take a look at the methods:



interface VirtualScrollStrategy {
  /** Emits when the index of the first element visible in the viewport changes. */
  scrolledIndexChange: Observable<number>;

  /**
   * Attaches this scroll strategy to a viewport.
   * @param viewport The viewport to attach this strategy to.
   */
  attach(viewport: CdkVirtualScrollViewport): void;

  /** Detaches this scroll strategy from the currently attached viewport. */
  detach(): void;

  /** Called when the viewport is scrolled (debounced using requestAnimationFrame). */
  onContentScrolled(): void;

  /** Called when the length of the data changes. */
  onDataLengthChanged(): void;

  /** Called when the range of items rendered in the DOM has changed. */
  onContentRendered(): void;

  /** Called when the offset of the rendered items changed. */
  onRenderedOffsetChanged(): void;

  /**
   * Scroll to the offset for the given index.
   * @param index The index of the element to scroll to.
   * @param behavior The ScrollBehavior to use when scrolling.
   */
  scrollToIndex(index: number, behavior: ScrollBehavior): void;
}


Enter fullscreen mode Exit fullscreen mode

Generally, we won't have to implement all of them, unless you want any of the omitted behavior. I'll hold off the details for now. First, let's implement the interface and name our custom strategy "HeroMessageVirtualScrollStrategy":



export class HeroMessageVirtualScrollStrategy implements VirtualScrollStrategy {
  // [...]
}


Enter fullscreen mode Exit fullscreen mode

In order to provide our custom strategy to the viewport component, we should use the VIRTUAL_SCROLL_STRATEGY injection token. We can achieve this by introducing a new directive:



@Directive({
  selector: '[appHeroMessageVirtualScroll]',
  providers: [
    {
      provide: VIRTUAL_SCROLL_STRATEGY,
      /* We will use `useFactory` and `deps` approach for providing the instance  */
      useFactory: (d: HeroMessageVirtualScrollDirective) => d._scrollStrategy,
      deps: [forwardRef(() => HeroMessageVirtualScrollDirective)],
    },
  ],
})
export class HeroMessageVirtualScrollDirective {
  /* Create an instance of the custom scroll strategy that we are going to provide  */
  _scrollStrategy = new HeroMessageVirtualScrollStrategy();
}


Enter fullscreen mode Exit fullscreen mode

After we are done, we would like to add some additional flavor – provide the list of messages, as an @Input, that we are going to use later in the scroll strategy. You will notice that the scroll strategy has one additional method called updateMessages. It will be added later by us but bare with me for now.



export class HeroMessageVirtualScrollDirective {
  // [...]

  private _messages: HeroMessage[] = [];

  @Input()
  set messages(value: HeroMessage[] | null) {
    if (value && this._messages.length !== value.length) {
      this._scrollStrategy.updateMessages(value);
      this._messages = value;
    }
  }
}


Enter fullscreen mode Exit fullscreen mode

What we've just done is to add a _messages property that is going to be updated and respectively sent to the scroll strategy when it changes*

* A change can be very subjective. The current check is very primitive but it can be altered depending on the use case.

💾: You can check the final file at GitHub

Summary

To summarize what we've done so far:

  1. Got familiar with the VirtualScrollStrategy interface
  2. Created our own strategy (although it's not implemented yet)
  3. Built a directive that will be used along with the virtual scroll component in order to plug our strategy, which bring us to the next chapter

III. Setting up the custom strategy

Since we now have the custom strategy, or at least the wireframe, let's plug it into the cdk-virtual-scroll-viewport in the template of our desired host component. In my case, I am going to use app.component.html.



  <cdk-virtual-scroll-viewport
    appHeroMessageVirtualScroll
    [messages]="heroMsgs.messages$ | async"
  >
    <!-- We'll use the HeroMessageComponent in order to render our list items -->
    <app-hero-message
      *cdkVirtualFor="let msg of heroMsgs.messages$ | async"
      [message]="msg"
      [attr.data-hm-id]="msg.id"
    ></app-hero-message>
  </cdk-virtual-scroll-viewport>


Enter fullscreen mode Exit fullscreen mode

Data source

Because the generation of data is not a point of interest for this article, you can check the mocked API service that performs this task at GitHub. I prefer not to clutter the article with unnecessary source code. The mocked data is provided to the app.component.ts via the service and respectively the template.

💾: Check the app component at GitHub

💾: Check the mocked API at GitHub

Infinite scrolling

Finally, since we want to be as close as possible to a real-world app, the mocked data is going to be continuously loaded while the user scrolls down the list. For this purpose, we will need to introduce infinite scrolling.

💾: You can check the final file at GitHub.

Summary

In this chapter we managed to:

  1. Plug the custom strategy to CdkVirtualScrollViewport
  2. Briefly showcase our data generation
  3. Add some infinite scrolling for the sake of real-world-ness

IV. Strategy implementation

We reached the point where we can start the implementation of the strategy. Logically, let's start with the attach and detach methods:



export class HeroMessageVirtualScrollStrategy implements VirtualScrollStrategy {
  private _viewport!: CdkVirtualScrollViewport | null;

  attach(viewport: CdkVirtualScrollViewport): void {
    this._viewport = viewport;
  }

  detach(): void {
    this._viewport = null;
  }
}


Enter fullscreen mode Exit fullscreen mode

As a next step, let's add the updateMessages custom method that will serve the HeroMessage-s to the strategy class:



export class HeroMessageVirtualScrollStrategy implements VirtualScrollStrategy {
  private _messages: HeroMessage[] = [];

  // [...]

  updateMessages(messages: HeroMessage[]) {
    this._messages = messages;

    if (this._viewport) {
      this._viewport.checkViewportSize();
    }
  }
}


Enter fullscreen mode Exit fullscreen mode

Now, it is time to introduce our main method which will dictate the rest of the implementation. The _updateRenderedRange must contain our core logic for determining the range of hero messages that should be rendered in the viewport. In short, we should be able to tell few things in order to implement it:

  • Measure the total height of the scrollable container
  • Determine the scroll position (or offset)
  • Determine the number of list items

As you can see, there is one key take here – we have to be able to distinguish and measure the size of the different list items. Of course, this task might become very tricky. This is why, we will go with a rough estimation. How will this happen? Let's examine the UI of the HeroMessage.

Determining the height of the messages (list items)

UI Examination

As you can see, the UI is not that complex – we have a hero, a date, some text, labels. The message has a minimal height determined by the existing elements (i.e. a hero message with a single line of text). Nevertheless, the height is unconstrained when it comes to growth (unless we introduce max. size for the text but this is not what we want in this article). Since we've already mentioned that we need a rough estimation, not a precise one, we can examine the styled component and its child elements and write down their rough sizes. Without further ado, let's introduce our height predictor.



// hero-message-height-predictor.ts

const Padding = 24 * 2;
const NameHeight = 21;
const DateHeight = 14;
const MessageMarginTop = 14;
const MessageRowHeight = 24;
const MessageRowCharCount = 35;
const TagsMarginTop = 16;
const TagsRowHeight = 36;
const TagsPerRow = 3;

export const heroMessageHeightPredictor = (m: HeroMessage) => {
  const textHeight =
    Math.ceil(m.text.length / MessageRowCharCount) * MessageRowHeight;

  const tagsHeight = m.tags.length
    ? TagsMarginTop + Math.ceil(m.tags.length / TagsPerRow) * TagsRowHeight
    : 0;

  return (
    Padding +
    NameHeight +
    DateHeight +
    MessageMarginTop +
    textHeight +
    tagsHeight
  );
};


Enter fullscreen mode Exit fullscreen mode

As you can see, it is a fairly simple function which estimates/predicts the height of a HeroMessage based on its data. The text and tags height estimation is pretty much a ballpark figure based on an approximate row length. However, this is more than enough for our needs at this stage. I guess it's needless to mention, and obvious, that for your own particular case you will have to develop a similar function that matches your own UI. Now, let's go back to the strategy implementation.

💾: You can check the file at GitHub.

Incorporating height prediction

Due to the need of a height estimation, we can directly start with a new private method which uses the predictor:



export class HeroMessageVirtualScrollStrategy implements VirtualScrollStrategy {
  private _heightCache = new Map<string, number>();

  // [...]

  private _getMsgHeight(m: HeroMessage): number {
    let height = 0;
    const cachedHeight = this._heightCache.get(m.id);

    if (!cachedHeight) {
      height = heroMessageHeightPredictor(m);
      this._heightCache.set(m.id, height);
    } else {
      height = cachedHeight;
    }

    return height;
  }
}


Enter fullscreen mode Exit fullscreen mode

You can notice that we've also added a cache property. Basically, our method will be memoized so we can avoid recalculations. We might have a lot of them when the user scrolls.

Next, let's introduce several other methods that are going to be based on/use this particular one. As mentioned above, for the _updateRenderedRange we will have to know things like total scroll height, message offset, etc. We will start with calculating the height of a set of messages, which is a fairly simple operation:



export class HeroMessageVirtualScrollStrategy implements VirtualScrollStrategy {
  // [...]

  private _measureMessagesHeight(messages: HeroMessage[]): number {
    return messages
      .map((m) => this._getMsgHeight(m))
      .reduce((a, c) => a + c, 0);
  }
}


Enter fullscreen mode Exit fullscreen mode

After that we can introduce the following private methods:



export class HeroMessageVirtualScrollStrategy implements VirtualScrollStrategy {
  // [...]

  /**
   * Returns the total height of the scrollable container
   * given the size of the elements.
   */
  private _getTotalHeight(): number {
    return this._measureMessagesHeight(this._messages);
  }

  /**
   * Returns the offset relative to the top of the container
   * by a provided message index.
   *
   * @param idx
   * @returns
   */
  private _getOffsetByMsgIdx(idx: number): number {
    return this._measureMessagesHeight(this._messages.slice(0, idx));
  }

  /**
   * Returns the message index by a provided offset.
   *
   * @param offset
   * @returns
   */
  private _getMsgIdxByOffset(offset: number): number {
    let accumOffset = 0;

    for (let i = 0; i < this._messages.length; i++) {
      const msg = this._messages[i];
      const msgHeight = this._getMsgHeight(msg);
      accumOffset += msgHeight;

      if (accumOffset >= offset) {
        return i;
      }
    }

    return 0;
  }
}


Enter fullscreen mode Exit fullscreen mode

We are getting pretty close to the implementation of _updateRenderedRange. In the code section above, we've added a method for measuring the total height, getting the scroll offset of a message index, and the reverse method – getting a message index by a scroll offset.

What is next is a method that determines the number of messages in the viewport given a start index of a message:



export class HeroMessageVirtualScrollStrategy implements VirtualScrollStrategy {
  // [...]

  private _determineMsgsCountInViewport(startIdx: number): number {
    if (!this._viewport) {
      return 0;
    }

    let totalSize = 0;
    // That is the height of the scrollable container (i.e. viewport)
    const viewportSize = this._viewport.getViewportSize();

    for (let i = startIdx; i < this._messages.length; i++) {
      const msg = this._messages[i];
      totalSize += this._getMsgHeight(msg);

      if (totalSize >= viewportSize) {
        return i - startIdx + 1;
      }
    }

    return 0;
  }
}


Enter fullscreen mode Exit fullscreen mode

After this short sprint of method implementations we are finally ready to introduce our key method that we've been mentioning since the beginning of this chapter – _updateRenderedRange. Its purpose is to build a range object composed by a start and end indices which is then provided to the viewport object via its API. You will also notice the existence of two constants called PaddingAbove and PaddingBelow. What they do is to instruct the virtual scroll to render some more messages before and after the scroll viewport. This way, we will have some rendered content that is not visible but needed for a better scrolling experience (i.e. the items won't have to be rendered at the last moment):


 typescript
const PaddingAbove = 5;
const PaddingBelow = 5;

export class HeroMessageVirtualScrollStrategy implements VirtualScrollStrategy {
  _scrolledIndexChange$ = new Subject<number>();
  scrolledIndexChange: Observable<number> = this._scrolledIndexChange$.pipe(
    distinctUntilChanged(),
  );

  // [...]

  private _updateRenderedRange() {
    if (!this._viewport) {
      return;
    }

    const scrollOffset = this._viewport.measureScrollOffset();
    const scrollIdx = this._getMsgIdxByOffset(scrollOffset);
    const dataLength = this._viewport.getDataLength();
    const renderedRange = this._viewport.getRenderedRange();
    const range = {
      start: renderedRange.start,
      end: renderedRange.end,
    };

    range.start = Math.max(0, scrollIdx - PaddingAbove);
    range.end = Math.min(
      dataLength,
      scrollIdx + this._determineMsgsCountInViewport(scrollIdx) + PaddingBelow,
    );

    this._viewport.setRenderedRange(range);
    this._viewport.setRenderedContentOffset(
      this._getOffsetByMsgIdx(range.start),
    );
    this._scrolledIndexChange$.next(scrollIdx);
  }
}


Enter fullscreen mode Exit fullscreen mode

So far, so good. You may remember the attach method that we implemented, right? We must update it now with the newly introduced _updateRenderedRange:



attach(viewport: CdkVirtualScrollViewport): void {
  this._viewport = viewport;

  // New code
  if (this._messages) {
    this._viewport.setTotalContentSize(this._getTotalHeight());
    this._updateRenderedRange();
  }
}


Enter fullscreen mode Exit fullscreen mode

We can now also add and onDataLengthChanged to the strategy class which is very similar in terms of code:



export class HeroMessageVirtualScrollStrategy implements VirtualScrollStrategy {
  // [...]

  onDataLengthChanged(): void {
    if (!this._viewport) {
      return;
    }

    this._viewport.setTotalContentSize(this._getTotalHeight());
    this._updateRenderedRange();
  }
}


Enter fullscreen mode Exit fullscreen mode

And finally, we need to update the rendered range when the user scrolls. This is simply managed by onContentScrolled:



export class HeroMessageVirtualScrollStrategy implements VirtualScrollStrategy {
  // [...]

  onContentScrolled(): void {
    if (this._viewport) {
      this._updateRenderedRange();
    }
  }
}


Enter fullscreen mode Exit fullscreen mode

At this stage we should have the internals of our new strategy ready. As a next step, we can implement some of the other methods – that are not so crucial for the operation of the virtual scroll – which are part of the VirtualScrollStrategy interface.

Complementary methods

This section will be very short. We will focus on 3 methods from which only one will be implemented.

For our purposes, we won't benefit from onContentRendered and onRenderedOffsetChanged. We don't need to perform actions when the content is rendered or the offset has changed. So:



export class HeroMessageVirtualScrollStrategy implements VirtualScrollStrategy {
  // [...]

  onContentRendered(): void {
    /** no-op */
  }

  onRenderedOffsetChanged(): void {
    /** no-op */
  }
}


Enter fullscreen mode Exit fullscreen mode

On the other hand, having scrollToIndex functionality supported by the virtual scroll strategy might be desired, so we can quickly implement it with the existing private methods that we added earlier in the process:



export class HeroMessageVirtualScrollStrategy implements VirtualScrollStrategy {
  // [...]

  scrollToIndex(index: number, behavior: ScrollBehavior): void {
    if (!this._viewport) {
      return;
    }

    const offset = this._getOffsetByMsgIdx(index);
    this._viewport.scrollToOffset(offset, behavior);
  }
}


Enter fullscreen mode Exit fullscreen mode

With this final addition we can mark the implementation of the VirtualScrollStrategy completed. In essence, we should now have a functional virtual scroll that is very likely sufficient for our needs. Anyway, this doesn't mean that we can't do some improvements.

Improving height measurement

With its current design, our virtual scroll strategy relies heavily on the message height predictor that we introduced for measuring the approximate height of the list item. This is fine, and will probably work good enough in a lot of situations. Nevertheless, there is an inherent flaw in this design – since the approximated heights of the list items are almost the same as – if not the same as in some cases – the real heights but never the same in all situations, the more items the virtual scroll renders, the more imprecise the measurement of the total height will become due to these small deviations. While they are insignificant when observed individually, their accumulation results in a significant height difference.

Our goal, of course, is to make these measurements as precise as possible. This can only happen, if we take the height of the already rendered list items – at least this is the easiest way unless we want to dwelve into more concrete calculations – and, if you think more about it, we actually already have the list items rendered at some point of the virtual scroll usage. To summarize: what will do is to update the approximate heights (predicted) with the real heights (actual) of the list items when they get rendered.

Let's start by modifying our existing code. First, we'll update the type of the _heightCache map:



private _heightCache = new Map<string, MessageHeight>();


Enter fullscreen mode Exit fullscreen mode

where the MessageHeight is defined by a new interface:



interface MessageHeight {
  value: number;
  source: 'predicted' | 'actual';
}


Enter fullscreen mode Exit fullscreen mode

We will need to differentiate between a predicted height and an actual height; hence, the change.

Next, let's fix _getMsgHeight since the updated type will result in some errors.



private _getMsgHeight(m: HeroMessage): number {
  // [...]

  if (!cachedHeight) {
    height = heroMessageHeightPredictor(m);

    // Values from the height predictor will be marked as `predicted`
    this._heightCache.set(m.id, { value: height, source: 'predicted' });
  } else {
    height = cachedHeight.value;
  }

  // [...]
}


Enter fullscreen mode Exit fullscreen mode

Now, since we need to get the actual heights from the DOM somehow, we will have to obtain a reference of the scroll wrapper. It makes sense to do that in the attach method of the strategy because that's the starting point of our code and that is where we set our viewport which can be used for that purpose.



export class HeroMessageVirtualScrollStrategy implements VirtualScrollStrategy {
  private _wrapper!: ChildNode | null;

  // [...]

  attach(viewport: CdkVirtualScrollViewport): void {
    this._viewport = viewport;
    this._wrapper = viewport.getElementRef().nativeElement.childNodes[0];

    // [...]
  }
}


Enter fullscreen mode Exit fullscreen mode

We are now getting even closer to the completion, but we need to add one additional private method to the strategy. We can name it _updateHeightCache:



export class HeroMessageVirtualScrollStrategy implements VirtualScrollStrategy {
  // [...]

  private _updateHeightCache() {
    if (!this._wrapper || !this._viewport) {
      return;
    }

    // Get a reference of the child nodes/list items
    const nodes = this._wrapper.childNodes;
    let cacheUpdated: boolean = false;

    for (let i = 0; i < nodes.length; i++) {
      const node = nodes[i] as HTMLElement;

      // Check if the node is actually an app-hero-message component
      if (node && node.nodeName === 'APP-HERO-MESSAGE') {
        // Get the message ID
        const id = node.getAttribute('data-hm-id') as string;
        const cachedHeight = this._heightCache.get(id);

        // Update the height cache, if the existing height is predicted
        if (!cachedHeight || cachedHeight.source !== 'actual') {
          const height = node.clientHeight;

          this._heightCache.set(id, { value: height, source: 'actual' });
          cacheUpdated = true;
        }
      }
    }

    // Reset the total content size only if there has been a cache change
    if (cacheUpdated) {
      this._viewport.setTotalContentSize(this._getTotalHeight());
    }
  }
}


Enter fullscreen mode Exit fullscreen mode

The method is a bit long but generally straightforward to grasp. You have probably noticed the existance of the data-hm-id attribute. Actually, this attribute has been part of our code since the beginning of chapter III but I intentionally decided to not point to its existance until now when you should hopefully realize its relevance and importance to our implementation. If not – it's there to help us easily distinguish the different messages in the DOM.

Finally, we will have to call that method somewhere. A good place is actually _updateRenderedRange, at the end after the rest of the statements.



export class HeroMessageVirtualScrollStrategy implements VirtualScrollStrategy {
  // [...]

  private _updateRenderedRange() {
    // [...]

    this._updateHeightCache();
  }
}


Enter fullscreen mode Exit fullscreen mode

At this point, we can now conclude that our implementation is complete. 🎉

Final Result

💾: Check the implemented custom strategy at GitHub

Final code

Throughout this article, I've given some links to the GitHub repository containing the example code which is based on this article. To make it even easier for you, here is the link of the whole repository:

hawkgs/hero-feed-virtual-scroll

Conclusion

The presented problem in this article can be abstracted for a lot of other use cases. The height predictor can be injected instead. All of that can result in a fairly generic implementation. I intentionally decided to not approach the problem that way. Anyhow, in reality, this implementation can be further improved or tailored for other needs; hence, it probably does not take into account more specific use cases.

What I really hope though is that by reading this piece of text you now have a good understanding how to implement your own VirtualScrollStrategy.

💖 💪 🙅 🚩
georgii
Georgi Serev

Posted on May 8, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related