Virtual scrolling of content with variable height with Angular
Tags: angular, frontend, typescript, web
Published: 2023-05-08 18:41:23

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](https://material.angular.io/cdk/scrolling/overview#scrolling-over-fixed-size-items)) 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](#final-code) section and explore the repository directly (the code is documented).

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

### Contents

<!-- no toc -->
- [I. Case study – Hero Feed](#i-case-study-hero-feed)
- [II. `VirtualScrollStrategy` – What is that?](#ii-virtualscrollstrategy-what-is-that)
- [III. Setting up the custom strategy](#iii-setting-up-the-custom-strategy)
  - [Data source](#data-source)
  - [Infinite scrolling](#infinite-scrolling)
- [IV. Strategy implementation](#iv-strategy-implementation)
  - [Determining the height of the messages (list items)](#determining-the-height-of-the-messages-list-items)
  - [Incorporating height prediction](#incorporating-height-prediction)
  - [Complementary methods](#complementary-methods)
  - [Improving height measurement](#improving-height-measurement)
  - [Final code](#final-code)
- [Conclusion](#conclusion)

## 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:

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

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

![HeroMessage Item](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/t1qa1sgn5u3zup7s7goo.png)

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](https://github.com/hawkgs/hero-feed-virtual-scroll/tree/main/src/app/hero-message)_

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:

```typescript
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;
}
```

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`":

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

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:

```typescript
@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();
}
```

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.

```typescript
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;
    }
  }
}
```

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](https://github.com/hawkgs/hero-feed-virtual-scroll/blob/main/src/app/virtual-scroll/hero-message-virtual-scroll.directive.ts)_

### 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`.

```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>
```

### 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](https://github.com/hawkgs/hero-feed-virtual-scroll/blob/main/src/app/app.component.ts)_

_💾: Check the mocked API at [GitHub](https://github.com/hawkgs/hero-feed-virtual-scroll/tree/main/src/app/api)_

### 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](https://github.com/hawkgs/hero-feed-virtual-scroll/blob/main/src/app/infinite-scroll/infinite-scroll.component.ts)._

### 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:

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

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

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

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

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

  // [...]

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

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

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/fpbvgkr8g6kgyj22fj0p.png)

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.

```typescript
// 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
  );
};
```

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](https://github.com/hawkgs/hero-feed-virtual-scroll/blob/main/src/app/virtual-scroll/hero-message-height-predictor.ts)._

### Incorporating height prediction

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

```typescript
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;
  }
}
```

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:

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

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

After that we can introduce the following private methods:

```typescript
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;
  }
}
```

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:

```typescript
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;
  }
}
```

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);
  }
}
```

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

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

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

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

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

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

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

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

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

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

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:

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

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

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

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:

```typescript
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);
  }
}
```

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:

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

where the `MessageHeight` is defined by a new interface:

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

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.

```typescript
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;
  }

  // [...]
}
```

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.

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

  // [...]

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

    // [...]
  }
}
```

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`:

```typescript
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());
    }
  }
}
```

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.

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

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

    this._updateHeightCache();
  }
}
```

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

![Final Result](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/v6rin8rpyt9naiec6m48.gif)

_💾: Check the implemented custom strategy at [GitHub](https://github.com/hawkgs/hero-feed-virtual-scroll/blob/main/src/app/virtual-scroll/hero-message-virtual-scroll.ts)_

### 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](https://github.com/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`.