Skip to main content

๐Ÿงช Virtual Scrolling

A high performance virtual scrolling implementation for Angular.

Instead of rendering every item provided, rxVirtualFor only renders what is currently visible to the user, thus providing excellent runtime performance for huge sets of data.

The technique to render items is comparable to the one used by twitter and explained in great detail by @DasSurma in his blog post about the complexities of infinite scrollers.

"Each recycling of a DOM element would normally relayout the entire runway which would bring us well below our target of 60 frames per second. To avoid this, we are taking the burden of layout onto ourselves and use absolutely positioned elements with transforms." (@DasSurma)

note

This package is currently experimental, the public API can potentially change

The @rx-angular/template/experimental/virtual-scrolling package is a performance focused alternative to the official @angular/cdk/scrolling.

The API is heavily inspired by the CDK implementation and is divided into multiple core components which have to be glued together:

  • RxVirtualViewRepeater, implemented by RxVirtualFor
  • RxVirtualScrollViewport, implemented by RxVirtualScrollViewportComponent
  • RxVirtualScrollStrategy, implemented by AutosizeVirtualScrollStrategy, FixedSizeVirtualScrollStrategy & DynamicSizeVirtualScrollStrategy

See the comparison section for an in-depth comparison to the Angular CDK implementation.

Usageโ€‹

list.component.html
<rx-virtual-scroll-viewport [itemSize]="50">
<div *rxVirtualFor="let movie of movies;">
<div><strong>{{ movie.name }}</strong></div>
<div>{{ movie.id }}</div>
<div>{{ movie.description }}</div>
</div>
</rx-virtual-scroll-viewport>
src/list.component.ts
import {
FixedSizeVirtualScrollStrategy, // choose any strategy you like
RxVirtualScrollViewportComponent,
RxVirtualFor,
} from '@rx-angular/template/experimental/virtual-scrolling';

@Component({
standalone: true,
imports: [RxVirtualFor, RxVirtualScrollViewportComponent, FixedSizeVirtualScrollStrategy],
})
export class ListComponent {
movies: Signal<Movie[]> = this.movieService.fetchMovies();
}
import {
FixedSizeVirtualScrollStrategy, // choose any strategy you like
RxVirtualScrollViewportComponent,
RxVirtualFor,
} from '@rx-angular/template/experimental/virtual-scrolling';

@Component({
standalone: true,
imports: [RxVirtualFor, RxVirtualScrollViewportComponent, FixedSizeVirtualScrollStrategy],
})
export class MyComponent {}
<rx-virtual-scroll-viewport [itemSize]="50">
<div *rxVirtualFor="let hero of heroes$;">
<div>
<div><strong>{{ hero.name }}</strong></div>
<div>{{ hero.id }}</div>
<div>{{ hero.description }}</div>
</div>
</div>
</rx-virtual-scroll-viewport>

Demoโ€‹

Check out the Demo Application. You can play around with all pre-packaged ScrollStrategies as well as control the majority of inputs.

Conceptsโ€‹

Featuresโ€‹

DX Features

  • reduces boilerplate (multiple async pipe's)
  • works also with static variables *rxVirtualFor="let i of myData"
  • Immutable as well as mutable data structures (trackBy)
  • Notify when rendering of templates is finished (renderCallback)

Performance Features

Usage Examplesโ€‹

Setupโ€‹

You have to import 3 parts in order to get started:

  • the Viewport
  • the ViewRepeater
  • the ScrollStrategy
import {
FixedSizeVirtualScrollStrategy, // ScrollStrategy
RxVirtualScrollViewportComponent, // Viewport
RxVirtualFor, // ViewRepeater
} from '@rx-angular/template/experimental/virtual-scrolling';

Module based setup:


@NgModule({
imports: [RxVirtualFor, RxVirtualScrollViewportComponent, FixedSizeVirtualScrollStrategy],
})
export class MyModule {}

Standalone component setup:

@Component({
standalone: true,
imports: [RxVirtualFor, RxVirtualScrollViewportComponent, FixedSizeVirtualScrollStrategy],
})
export class MyComponent {}

Fixed size virtual-scroll using *rxVirtualFor with Observable valuesโ€‹

<rx-virtual-scroll-viewport [itemSize]="50">
<div class="hero" *rxVirtualFor="let hero of heroes$;">
<div>
<div><strong>{{ hero.name }}</strong></div>
<div>{{ hero.id }}</div>
<div>{{ hero.description }}</div>
</div>
</div>
</rx-virtual-scroll-viewport>
.hero {
height: 50px; // this is important, the items have to be sized properly
}
@Component({})
export class AnyComponent {
heroes$: Observable<Hero[]> = getHeroes();
}

๐Ÿ’ก See examples for other scroll strategies here

Fixed size virtual-scroll using *rxVirtualFor with static valuesโ€‹

<rx-virtual-scroll-viewport [itemSize]="50">
<div class="hero" *rxVirtualFor="let hero of heroes">
<div>
<div><strong>{{ hero.name }}</strong></div>
<div>{{ hero.id }}</div>
<div>{{ hero.description }}</div>
</div>
</div>
</rx-virtual-scroll-viewport>
.hero {
height: 50px; // this is important, the items have to be sized properly
}
@Component({})
export class AnyComponent {
heroes: Hero[] = getHeroes();
}

๐Ÿ’ก See examples for other scroll strategies here

appendOnly modeโ€‹

Append items to the list as the user scrolls without removing rendered views. The appendOnly input ensures views that are already rendered persist in the DOM after they scroll out of view.

This might be useful when integrating with the @angular/cdk/drag-drop package.

<rx-virtual-scroll-viewport [itemSize]="50" appendOnly>
<div class="hero" *rxVirtualFor="let hero of heroes; trackBy: 'id'">
<div>
<div><strong>{{ hero.name }}</strong></div>
<div>{{ hero.id }}</div>
<div>{{ hero.description }}</div>
</div>
</div>
</rx-virtual-scroll-viewport>

๐Ÿ’ก the [appendOnly] input reacts to changes, you can toggle it on runtime

๐Ÿ’ก see the angular/cdk implementation

Using trackBy shortcut to reduce boilerplateโ€‹

The trackBy input either takes a keyof T or the regular TrackByFunction ((index: number, item: T) => any) as a value.

<rx-virtual-scroll-viewport [itemSize]="50">
<div class="hero" *rxVirtualFor="let hero of heroes; trackBy: 'id'">
<div>
<div><strong>{{ hero.name }}</strong></div>
<div>{{ hero.id }}</div>
<div>{{ hero.description }}</div>
</div>
</div>
</rx-virtual-scroll-viewport>

๐Ÿ’ก See examples for other scroll strategies here

Using the static context variablesโ€‹

The following context variables are available for each template:

Static Context Variables (mirrored from ngFor)

Variable NameTypedescription
$implicitTthe default variable accessed by let val
indexnumbercurrent index of the item
countnumbercount of all items in the list
firstbooleantrue if the item is the first in the list
lastbooleantrue if the item is the last in the list
evenbooleantrue if the item has on even index (index % 2 === 0)
oddbooleanthe opposite of even

Usage

<rx-virtual-scroll-viewport [itemSize]="50">
<div
*rxVirtualFor="
let item of items;
let count = count;
let index = index;
let first = first;
let last = last;
let even = even;
let odd = odd;
"
>
<div>{{ count }}</div>
<div>{{ index }}</div>
<div>{{ item }}</div>
<div>{{ first }}</div>
<div>{{ last }}</div>
<div>{{ even }}</div>
<div>{{ odd }}</div>
</div>
</rx-virtual-scroll-viewport>

Using the reactive context variablesโ€‹

The following reactive context variables are available for each template:

Reactive Context Variables

Variable NameTypedescription
item$Observable<T>the same value as $implicit, but as Observable
index$Observable<number>index as Observable
count$Observable<number>count as Observable
first$Observable<boolean>first as Observable
last$Observable<boolean>last as Observable
even$Observable<boolean>even as Observable
odd$Observable<boolean>odd as Observable
select(keys: (keyof T)[], distinctByMap) => Observable<Partial<T>>returns a selection function which accepts an array of properties to pluck out of every list item. The function returns the selected properties of the current list item as distinct Observable key-value-pair.

Usage

<rx-virtual-scroll-viewport [itemSize]="50">
<div
*rxVirtualFor="
let item of observableItems$; trackBy: 'id';
let count$ = count$;
let index$ = index$;
let first$ = first$;
let last$ = last$;
let even$ = even$;
let odd$ = odd$;
"
>
<div *rxLet="count$; let c">{{ c }}</div>
<!---->
</div>
</rx-virtual-scroll-viewport>

Custom Scroll Elementsโ€‹

It is also possible to define a scroll container which is not part of the rx-virtual-scroll-viewport itself. This is useful if you like to include other containers into the scroll container of your virtual list.

You find demos for custom scroll elements in the demos app of the rx-angular monorepo

Perf notice

The included RxVirtualScrollStrategys are smart enough to detect if they are completely hidden from the viewport, by also accounting for the size of the contents before & after. When not part of the viewport, it will render less views (minimum of runwayItems or runwayItemsOpposite).

With user defined scroll elementโ€‹

In order to define a custom scroll container, you need to apply the rxVirtualScrollElement directive to the element you would like to act as scroll container for the virtual scroll viewport.

<div rxVirtualScrollElement>
<div>You can place any content you like before</div>
<rx-virtual-scroll-viewport [itemSize]="50">
<div *rxVirtualFor="let item of items$;">{{ item }}</div>
</rx-virtual-scroll-viewport>
<div>You can also place any content you like after</div>
</div>

With window scrollโ€‹

@rx-angular/template also ships a directive to use the window as scroll element. This is especially useful for mobile applications. To enable window scrolling, simply add the scrollWindow directive to the rx-virtual-scroll-viewport.

<rx-virtual-scroll-viewport [itemSize]="50" scrollWindow>
<div *rxVirtualFor="let item of items$;">{{ item }}</div>
</rx-virtual-scroll-viewport>

Reverse Infinite Scroll (keepScrolledIndexOnPrepend)โ€‹

Infinite Scrolling != Virtual Scrolling

Infinite scrolling is a scrollable pagination. Instead of loading all elements at once, they are added to the list when the user hits a certain scroll position. By default you are starting at the top and new data is appended to the list, when the user hits the bottom of the list.

When implementing a reversed infinite scroller, you are starting from the bottom of the list and prepend data when users hit the top of the list. This is usually the case for chat windows like whatsapp.

In order to support this behavior, the virtual scroll strategies need to adjust the scrolling behavior. They should keep the currently scrolled to index stable when new data is prepended to the list.

You can tell the scroll strategies to do so by setting the keepScrolledIndexOnPrepend flag to true.

See the following example implementation.

reverse-infinite-list.component.ts
import { AutoSizeVirtualScrollStrategy, ListRange, RxVirtualFor, RxVirtualScrollViewportComponent } from '@rx-angular/template/experimental/virtual-scrolling';

@Component({
imports: [RxVirtualScrollViewportComponent, RxVirtualFor, AutoSizeVirtualScrollStrategy],
})
export class ReverseInfiniteListComponent {
initialScrollIndex = 19;

private dataService = inject(MessageService);
// the currently rendered list range
listRange: ListRange = { start: 0, end: 0 };
// attach to scrollIndexChanged
scrolled$ = new Subject<number>();
messages$ = this.scrolled$.pipe(
// only fetch when hitting the start
filter(() => this.listRange.start === 0),
// start with the first request
startWith(0),
// index will be the page we want to fetch
exhaustMap((_, index) => {
return this.dataService.getMessages(index);
}),
scan(
(messages, newMessages) => [
...newMessages, // <- append new messages
...messages,
],
[],
),
);

trackMessage = (index: number, message: Message) => {
return message.id;
};
}

This is how the implementation looks like in real life, based on the example given in our demos application

Advanced Usageโ€‹

Use render strategies (strategy)โ€‹

You can change the used RenderStrategy by using the strategy input of the *rxVirtualFor. It accepts an Observable<RxStrategyNames> or RxStrategyNames.

The default value for strategy is normal.

<rx-virtual-scroll-viewport [itemSize]="50">
<div
*rxVirtualFor="
let item of items$; strategy: strategy
"
>
{{ item }}
</div>
</rx-virtual-scroll-viewport>

<rx-virtual-scroll-viewport [itemSize]="50">
<div
*rxVirtualFor="
let item of items$; strategy: strategy$
"
>
{{ item }}
</div>
</rx-virtual-scroll-viewport>
@Component()
export class AppComponent {
strategy = 'low';
strategy$ = of('immediate');
items$ = fetchItems();
}

Learn more about the general concept of RenderStrategies especially the section usage-in-the-template if you need more clarity.

Local strategies and view/content queries (parent)โ€‹

By default, *rxVirtualFor has turned the parent flag off. This means you are unable to rely on any content or view queries. Read more about this at handling view and content queries

Use the renderCallbackโ€‹

The renderCallback can be seen as hook into the change detection system. It's essentially a Subject which emits whenever *rxVirtualFor finished rendering a set changes to the view. This enables developers to perform actions when a list has finished rendering. The renderCallback is useful in situations where you rely on specific DOM properties like the height a table after all items got rendered, or to adjust scroll-positions. It is also possible to use the renderCallback in order to determine if a view should be visible or not. This way developers can hide a list as long as it has not finished rendering.

The result of the renderCallback will contain the currently rendered set of items in the iterable.

<rx-virtual-scroll-viewport [itemSize]="50">
<div
*rxVirtualFor="
let item of items$; renderCallback: itemsRendered
"
>
<div>{{ item.name }}</div>
</div>
</rx-virtual-scroll-viewport>
@Component({
/**/
})
export class AppComponent {
items$: Observable<Item[]> = itemService.getItems();

// this emits whenever rxVirtualFor finished rendering changes
itemsRendered = new Subject<Item[]>();

constructor() {
itemsRendered.subscribe(() => {
// items are rendered, we can do something now, e.g. hide a skeleton
});
}
}

Working with event listeners (patchZone)โ€‹

A flag to control whether *rxVirtualFor templates are created within NgZone or not. The default value is true (configurable via RxRenderStrategiesConfig or as input), *rxVirtualFor will create it's EmbeddedViews inside NgZone.

Event listeners normally trigger zone. Especially high frequently events cause performance issues.

For more details read about NgZone optimizations

Example with patchZone: false

<rx-virtual-scroll-viewport [itemSize]="50">
<div
*rxVirtualFor="
let bgColor; in: bgColor$; patchZone: false
"
(mousemove)="calcBgColor($event)"
[style.background]="bgColor"
></div>
</rx-virtual-scroll-viewport>
@Component(/**/)
export class AppComponent {
// As the part of the template where this function is used as event listener callback
// has `patchZone` false the all event listeners run outside zone.
calcBgColor(moveEvent: MouseEvent) {
// this function will run outside of angular's zone
// do something with the background in combination with the mouse position
}
}

Components & Directivesโ€‹

RxVirtualForโ€‹

The *rxVirtualFor structural directive implements the RxVirtualViewRepeater and is responsible to create, update, move and remove views from the bound data. As RxFor, RxVirtualFor treats each child template as single renderable unit. By default the change detection of the child templates get prioritized, scheduled and executed by leveraging RenderStrategies under the hood. This technique enables non-blocking rendering of lists and can be referred to as concurrent mode.

Read more about the concurrent mode in the concurrent strategies section in the RxAngular docs.

Inputsโ€‹

InputTypedescription
trackBykeyof T or (index: number, item: T) => anyIdentifier function for items. rxVirtualFor provides a shorthand where you can name the property directly.
patchZonebooleandefault: true if set to false, the RxVirtualForDirective will operate out of NgZone. See NgZone optimizations
parentbooleandefault: false if set to false, the RxVirtualForDirective won't inform its host component about changes being made to the template. More performant, @ViewChild and @ContentChild queries won't work. Handling view and content queries
strategyObservable<RxStrategyNames \ string> \ RxStrategyNames \ string>default: normal configure the RxStrategyRenderStrategy used to detect changes. Render Strategies
renderCallbackSubject<U>giving the developer the exact timing when the RxVirtualForDirective created, updated, removed its template. Useful for situations where you need to know when rendering is done.
templateCacheSizenumberdefault: 20 Controls the amount if views held in cache for later re-use when a user is scrolling the list If this is set to 0, rxVirtualFor won't cache any view, thus destroying & re-creating very often on scroll events.

RxVirtualScrollViewportComponentโ€‹

Container component comparable to CdkVirtualScrollViewport acting as viewport for *rxVirtualFor to operate on. Its main purpose is to implement the RxVirtualScrollViewport interface as well as maintaining the scroll runways' height in order to give the provided RxVirtualScrollStrategy room to position items. Furthermore, it will gather and forward all events to the consumer of rxVirtualFor.

Inputsโ€‹

OutputTypedescription
initialScrollIndexnumberSets the first view to be visible to the user. The viewport waits for the data to arrive and scrolls to the given index immediately.

Outputsโ€‹

OutputTypedescription
viewRangeListRange: { start: number; end: number; }The range to be rendered by *rxVirtualFor. This value is determined by the provided RxVirtualScrollStrategy. It gives the user information about the range of items being actually rendered to the DOM. Note this value updates before the renderCallback kicks in, thus it is only in sync with the DOM when the next renderCallback emitted an event.
scrolledIndexChangenumberThe index of the currently scrolled item. The scrolled item is the topmost item actually being visible to the user.

RxVirtualScrollStrategyโ€‹

The RxVirtualScrollStrategy is responsible for positioning the created views on the viewport. The three pre-packaged scroll strategies share similar concepts for layouting views. All of them provide a twitter-like virtual-scrolling implementation, where views are positioned absolutely and transitioned by using css transforms. They also share two inputs to define the amount of views to actually render on the screen.

InputTypedescription
runwayItemsnumberdefault: 10 The amount of items to render upfront in scroll direction
runwayItemsOppositenumberdefault: 2 The amount of items to render upfront in reverse scroll direction
keepScrolledIndexOnPrependbooleandefault: false If this flag is true, the virtual scroll strategy maintains the scrolled item when new data is prepended to the list. This is very useful when implementing a reversed infinite scroller, that prepends data instead of appending it

See the layouting technique in action in the following video. It compares @rx-angular/template vs. @angular/cdk/scrolling

FixedSizeVirtualScrollStrategyโ€‹

The FixedSizeVirtualScrollStrategy positions views based on a fixed size per item. It is comparable to @angular/cdk/scrolling FixedSizeVirtualScrollStrategy, but with a high performant layouting technique.

Demo

The default size can be configured directly as @Input('itemSize').

Example

// my.component.ts
import {
FixedSizeVirtualScrollStrategyModule,
RxVirtualScrollViewportComponent,
RxVirtualFor,
} from '@rx-angular/template/experimental/virtual-scrolling';

@Component({
/**/,
standalone: true,
imports: [RxVirtualFor, FixedSizeVirtualScrollStrategyModule, RxVirtualScrollViewportComponent]
})
export class MyComponent {
// all items have the height of 50px
itemSize = 50;

items$ = inject(DataService).getItems();
}
<rx-virtual-scroll-viewport [itemSize]="itemSize">
<div class="item" *rxVirtualFor="let item of items$;">
<div>{{ item.id }}</div>
<div>{{ item.content }}</div>
<div>{{ item.status }}</div>
<div>{{ item.date | date }}</div>
</div>
</rx-virtual-scroll-viewport>

DynamicSizeVirtualScrollStrategyโ€‹

The DynamicSizeVirtualScrollStrategy is very similar to the AutoSizeVirtualScrollStrategy. Instead of hitting the DOM, it calculates the size based on a user provided function of type (item: T) => number. Because it doesn't have to interact with the DOM in order to position views, the DynamicSizeVirtualScrollStrategy has a better runtime performance compared to the AutoSizeVirtualScrollStrategy.

This strategy is very useful for scenarios where you display different kind of templates, but already know the dimensions of them.

Demo

Example

// my.component.ts
import {
DynamicSizeVirtualScrollStrategy,
RxVirtualScrollViewportComponent,
RxVirtualFor,
} from '@rx-angular/template/experimental/virtual-scrolling';

@Component({
/**/,
standalone: true,
imports: [RxVirtualFor, DynamicSizeVirtualScrollStrategy, RxVirtualScrollViewportComponent]
})
export class MyComponent {
// items with a description have 120px height, others only 50px
dynamicSize = (item: Item) => (item.description ? 120 : 50);

items$ = inject(DataService).getItems();
}
<!--my.component.html-->
<rx-virtual-scroll-viewport [dynamic]="dynamicSize">
<div class="item" *rxVirtualFor="let item of items$;">
<div>{{ item.id }}</div>
<div>{{ item.content }}</div>
<div>{{ item.status }}</div>
<div>{{ item.date | date }}</div>
<div *ngIf="item.description">{{ item.description }}</div>
</div>
</rx-virtual-scroll-viewport>

AutoSizeVirtualScrollStrategyโ€‹

The AutoSizeVirtualScrollStrategy is able to render and position items based on their individual size. It is comparable to @angular/cdk/experimental AutoSizeVirtualScrollStrategy, but with a high performant layout technique, better visual stability and added features. Furthermore, the AutoSizeVirtualScrollStrategy is leveraging the ResizeObserver in order to detect size changes for each individual view rendered to the DOM and properly re-position accordingly.

For views it doesn't know yet, the AutoSizeVirtualScrollStrategy anticipates a certain size in order to properly size the runway. The size is determined by the @Input('tombstoneSize') and defaults to 50.

In order to provide top runtime performance the AutoSizeVirtualScrollStrategy builds up caches that prevent DOM interactions whenever possible. Once a view was visited, its properties will be stored instead of re-read from the DOM again as this can potentially lead to unwanted forced reflows.

Demo

Example

// my.component.ts
import {
AutoSizeVirtualScrollStrategy,
RxVirtualScrollViewportComponent,
RxVirtualFor,
} from '@rx-angular/template/experimental/virtual-scrolling';

@Component({
/**/,
standalone: true,
imports: [RxVirtualFor, AutoSizeVirtualScrollStrategy, RxVirtualScrollViewportComponent]
})
export class MyComponent {
items$ = inject(DataService).getItems();
}
<rx-virtual-scroll-viewport autosize>
<div class="item" *rxVirtualFor="let item of items$;">
<div>{{ item.id }}</div>
<div>{{ item.content }}</div>
<div>{{ item.status }}</div>
<div>{{ item.date | date }}</div>
</div>
</rx-virtual-scroll-viewport>

Configurationโ€‹

RX_VIRTUAL_SCROLL_DEFAULT_OPTIONSโ€‹

By providing a RX_VIRTUAL_SCROLL_DEFAULT_OPTIONS token, you can pre-configure default settings for the directives of the @rx-angular/template/experimental/virtual-scrolling package.

import { RX_VIRTUAL_SCROLL_DEFAULT_OPTIONS } from '@rx-angular/template/experimental/virtual-scrolling';

@NgModule({
providers: [{
provide: RX_VIRTUAL_SCROLL_DEFAULT_OPTIONS,
useValue: { // should be of type `RxVirtualScrollDefaultOptions`
runwayItems: 50,
// turn off cache by default
templateCacheSize: 0
}
}]
})

Default Valuesโ€‹

/* determines how many templates can be cached and re-used on rendering */
const DEFAULT_TEMPLATE_CACHE_SIZE = 20;
/* determines how many views will be rendered in scroll direction */
const DEFAULT_ITEM_SIZE = 50;
/* determines how many views will be rendered in the opposite scroll direction */
const DEFAULT_RUNWAY_ITEMS = 10;
/* default item size to be used for scroll strategies. Used as tombstone size for the autosized strategy */
const DEFAULT_RUNWAY_ITEMS_OPPOSITE = 2;

RxVirtualScrollDefaultOptionsโ€‹

export interface RxVirtualScrollDefaultOptions {
/* determines how many templates can be cached and re-used on rendering, defaults to 20 */
templateCacheSize?: number;
/* determines how many views will be rendered in scroll direction, defaults to 15 */
runwayItems?: number;
/* determines how many views will be rendered in the opposite scroll direction, defaults to 5 */
runwayItemsOpposite?: number;
/* default item size to be used for scroll strategies. Used as tombstone size for the autosized strategy */
itemSize?: number;
}

Extend RxVirtualScrollingโ€‹

As all parts of the Virtual Scrolling package are based on InjectionTokens, you can easily extend this package by creating your own components and provide the according token.

Custom ScrollStrategiesโ€‹

To provide a custom RxVirtualScrollStrategy, you want to create a new directive. The directive should provide itself as RxVirtualScrollStrategy and also implement its interface. You might want to extend from RxVirtualScrollStrategy as it already has some helper functions implemented.

import { RxVirtualScrollStrategy } from '@rx-angular/template/experimental/virtual-scrolling';

@Directive({
selector: 'rx-virtual-scroll-viewport[custom]',
providers: [
{
provide: RxVirtualScrollStrategy,
},
],
})
export class CustomScrollStrategy extends RxVirtualScrollStrategy {}

Comparison with Angular CDKโ€‹

As this package solves the same problem as the Angular CDK Scrolling package, this section covers a brief feature comparison between both implementations and a performance comparison.

Feature Overviewโ€‹

RxAngularAngular CDK
NgZone agnosticโœ…โŒ
layout containmentโœ…โœ…
layout techniqueabsolutely position each viewtransform a container within the viewport
scheduling techniqueRenderStrategiesrequestAnimationFrame
renderCallbackโœ…โŒ
SSRโš  - to be testedโœ…
Define visible view bufferconfigurable amount of views displayed in scroll direction,
and opposite scroll direction
configurable buffer in px
trackByโœ…โœ…
View recyclingโœ…โœ…
Support scrollToIndexโœ…โœ…
FixedSizeStrategyโœ…โœ…
AutosizeStrategyโœ…โœ… - โš ๏ธ scrollToIndex & scrolledIndex are not supported
DynamicSizeStrategyโœ…โŒ
Viewport orientationโŒ - plannedโœ…
Separate viewport and scrolling elementโŒ - plannedโœ…
Tombstone / placeholder viewsโŒ - plannedโŒ

For more information about the planned features for this package, see the further improvements section.

Layout Techniqueโ€‹

The biggest difference between the two implementations lies within the applied layouting technique.

Two main tasks have to be considered when layouting a virtual viewport. The sizing of the scrollable area (runway) and keeping the viewport (visible part to the user) in sync with the user defined scroll position.

viewport and runway

screenshot taken from https://developer.chrome.com/blog/infinite-scroller/

Runway sizingโ€‹

A minor, but still notable difference is by how the two implementations size their runway. The Angular CDK implementation sizes its viewport by adjusting the height style of a spacer div. This results in one extremely large layer that puts pressure on the devices memory by storing a texture on the graphics card that potentially has a height of a couple of hundred thousand pixels.

cdk-container-size

In this example, the layers tool estimates a memory footprint of ~5GB for a runway with 30.000 items. This number is only an estimate, and we couldn't see such high memory consumption on the actual device, but it stresses the point.

layer-memory-estimate

๐Ÿ’ก You can counter this issue by making sure this layer is completely empty. It will be empty if it has no own paint area (e.g. background-color) and all items are forced into their own layers (e.g. using will-change: transform)

Another minor, but notable point is that changing an elements height property always forces the browser to perform a layout operation. In certain situations this can lead to more work than actually needed.

css-triggers height

screenshot taken from https://www.lmame-geek.com/

The RxAngular implementation uses a 1px by 1px element with a transform to simulate the desired height for the runway. The actual DOM element won't grow beyond its boundaries. While this alone is already an improvement, in best case still all items within the runway are enforced on their own layer (e.g. using will-change: transform) to make sure the runway layer is completely empty.

rxa-scroll-sentinel

As the runway is sized using the transform css property, we also don't run into the situation where resizing the runway would cause any layout work for the browser.

css-triggers transform

screenshot taken from https://www.lmame-geek.com/

Maintaining the viewportโ€‹

The Angular CDK implementation positions its list-items relative, letting the browser do all the layout work. The items are layouted naturally within a separate container element which is only as large as the items it contains. It is absolutely positioned to the viewport. To keep the visible items with the viewport in sync, the whole container is moved by the css transform on scroll events.

cdk-container-transform

As a user scrolls the viewport, the cdk virtual scroller calculates the range of items to be displayed. The transform value for the container is derived from the range and the actual view sizes.

// fixed-size-virtual-scroll.ts
// https://github.com/angular/components/blob/main/src/cdk/scrolling/fixed-size-virtual-scroll.ts#L177

this._viewport.setRenderedContentOffset(this._itemSize * newRange.start);

The RxAngular implementation calculates the position for each list item within the runway and absolutely positions each item individually with transforms.

rxa-item-transform

As the layout is done entirely manually, it essentially removes the need for the browser to layout any item within the viewport. This is especially true for updates, moves and insertions from cache. "Ideally, items would only get repainted once when they get attached to the DOM and be unfazed by additions or removals of other items in the runway."

(Surma - https://developer.chrome.com/blog/infinite-scroller/#layout)

Furthermore, it allows to implement advanced features such as scrollToIndex or emitting a scrolledIndex for the AutosizeVirtualScrollStrategy. "Since we are doing layout ourselves, we can cache the positions where each item ends up and we can immediately load the correct element from cache when the user scrolls backwards."

(Surma - https://developer.chrome.com/blog/infinite-scroller/#layout)

Schedulingโ€‹

Another major difference is the applied scheduling technique to run "change detection" - applying updates to the DOM. The Angular CDK package uses the requestAnimationFrame to debounce the calculation of the new view range and to run change detection.

All calculated changes will be evaluated synchronously within the very same animationFrameCallback. This can put a lot of javascript & layout work into a single task. Especially when using a weak device or rendering heavy components as list items, this technique will inevitably result in long tasks and can result in scroll stuttering. See the Performance Comparison section for more information about the actual runtime performance.

cdk-fixed-size--throttled

RxAngular's virtual scrolling implementation also uses the requestAnimationFrame scheduler, but not for change detection. It is used for coalescing scroll events and calculation of changes to the view range.

The scheduling being used for running change detection is configurable, by default it uses the normal Concurrent Strategy. In short, the concurrent strategies batch work into pieces to match a certain frame budget (60fps by default). Changes to the view range get translated into individual work packages to insert, move, update, delete and position views. The work packages are then processed individually by keeping the frame budget in mind.

rxa-fixed-size--throttled

This technique excels in keeping long tasks at a minimum and is especially helpful to render hefty components and/or supporting weak devices. It helps keeping the scrolling and bootstrap behavior buttery smooth.

See the Performance Comparison section for more information about the actual runtime performance.

Performance Comparisonโ€‹

Performance recordings are taken from the Demo Application. The demo application by default displays lists of 30 000 items.

The scenario that was benchmarked here is scrolling over long distances by using the scroll bar. This scenario puts the most pressure on the virtual scrollers.

System Setupโ€‹

OSPop!_OS 22.04 LTS
BrowserChromium Version 112.0.5615.49 (Official Build) (64-bit)
ProcessorIntelยฎ Coreโ„ข i7-9750H CPU @ 2.60GHz ร— 12

Different Layout techniquesโ€‹

The RxVirtualScrolling approach to layout items is to absolutely position every view inside the viewport. Therefore, it sets the transform property for each managed item. The CDK approach instead transforms the viewport. The following video showcases the difference.

Fixed Size Strategyโ€‹

Comparison between RxAngular FixedSizeVirtualScrollStrategy and CdkFixedSizeVirtualScroll.

FixedSizeVirtualScrollStrategy comparison Demo

Featuresโ€‹

Feature@rx-angular/template/experimental/virtual-scrolling@angular/cdk/scrolling
scrolledIndex$โœ…โœ…
scrollToIndex()โœ…โœ…

Performanceโ€‹

No throttling

Both solutions do fine without throttling. But, the CdkFixedSizeVirtualScroll already struggles with the frame rate. We can already spot partially presented frames. Also, the javascript tasks are taking longer compared to the RxAngular FixedSizeVirtualScrollStrategy.

@rx-angular/template/experimental/virtual-scrolling@angular/cdk/scrolling
rxa-fixed-size--unthrottledcdk-fixed-size--unthrottled

4x CPU throttling

With throttling enabled, the CdkFixedSizeVirtualScroll already struggles a lot with keeping the frame rate above anything reasonable. Javascript tasks take up to ~160ms (long-tasks) and the amount of partially presented frames increases. The RxAngular FixedSizeVirtualScrollStrategy has no issues whatsoever keeping the frame rate above 30fps on 4x times throttling.

@rx-angular/template/experimental/virtual-scrolling@angular/cdk/scrolling
rxa-fixed-size--throttledcdk-fixed-size--throttled

Dynamic Size Strategyโ€‹

Comparison between RxAngular DynamicSizeVirtualScrollStrategy and CDK AutoSizeVirtualScrollStrategy. As there is no real counterpart to the DynamicSizeVirtualScrollStrategy, the comparison was made against the CDK AutoSizeVirtualScrollStrategy. This is scroll behavior wise the most comparable implementation from the cdk package.

DynamicSizeVirtualScrollStrategy comparison Demo

Featuresโ€‹

As an experimental package, the CDK AutoSizeVirtualScrollStrategy does not emit the current scrollIndex, nor has it a working scrollToIndex method implemented. The RxAngular DynamicSizeVirtualScrollStrategy is able to do both! It emits the current valid scrolledIndex and is able to properly scroll to the correct position based on an index.

Feature@rx-angular/template/experimental/virtual-scrolling@angular/cdk/scrolling
scrolledIndex$โœ…โŒ
scrollToIndex()โœ…โŒ

Performanceโ€‹

No throttling

Both solutions do fine without throttling. But, the CDK AutoSizeVirtualScrollStrategy struggles with the frame rate. We can already spot lots of partially presented frames. The RxAngular DynamicSizeVirtualScrollStrategy implementation easily maintains a stable framerate around 45fps.

@rx-angular/template/experimental/virtual-scrolling@angular/cdk/scrolling
rxa-dynamic-size--unthrottled.pngcdk-autosize--unthrottled.png

4x CPU throttling

With throttling enabled, the CDK AutoSizeVirtualScrollStrategy struggles a lot with keeping the frame rate above anything reasonable. Javascript tasks take up more than ~160ms (long-tasks) and the amount of partially presented frames increases. The RxAngular DynamicSizeVirtualScrollStrategy has no issues whatsoever keeping the frame rate above 30fps on 4x times throttling. The javascript execution time is still very low, the style recalculations and layouting phases are increasing, though. This will also depend very much on the actual use case.

@rx-angular/template/experimental/virtual-scrolling@angular/cdk/scrolling
rxa-dynamic-size--throttled.pngcdk-autosize--throttled.png

Autosize Strategyโ€‹

Comparison between RxAngular AutoSizeVirtualScrollStrategy and CDK AutoSizeVirtualScrollStrategy.

AutoSizeVirtualScrollStrategy comparison Demo

Featuresโ€‹

As an experimental package, the CDK AutoSizeVirtualScrollStrategy does not emit the current scrollIndex, nor has it a working scrollToIndex method implemented. The RxAngular AutoSizeVirtualScrollStrategy is able to do both! It emits the current valid scrolledIndex and is able to properly scroll to the correct position based on an index.

Feature@rx-angular/template/experimental/virtual-scrolling@angular/cdk/scrolling
scrolledIndex$โœ…โŒ
scrollToIndex()โœ…โŒ

Performanceโ€‹

No throttling

For the CDK AutoSizeVirtualScrollStrategy, the same is true as for the comparison vs. the DynamicSizeVirtualScrollStrategy. The RxAngular AutoSizeVirtualScrollStrategy implementation easily maintains a stable framerate of 60fps. You see the reason why it can maintain this framerate in the comparison flameshots. The AutoSizeVirtualScrollStrategy puts all the layouting work into the RxAngular scheduler queue which will keep the framebudget for us. For each inserted view, the AutoSizeVirtualScrollStrategy will cause a forced reflow as it immediately reads its dimensions. It sounds like a disadvantage, but in reality the scrolling performance benefits from this approach. Anyway, that's why we such heavy rendering peaks (purple color). Nodes that were visited once are not queried again, scrolling the same path twice will differ in runtime performance. All consequent attempts should be as fast as the fixed or dynamic size implementations.

@rx-angular/template/experimental/virtual-scrolling@angular/cdk/scrolling
rxa-autosize--unthrottled.pngcdk-autosize--unthrottled.png

4x CPU throttling

For the CDK AutoSizeVirtualScrollStrategy, the same is true as for the comparison vs. the DynamicSizeVirtualScrollStrategy.

Even with 4x CPU throttling enabled, the RxAngular AutoSizeVirtualScrollStrategy keeps a reasonable frame rate and only sometimes produces partially presented frames. Thanks to the concurrent strategies, users will never encounter long tasks while scrolling.

@rx-angular/template/experimental/virtual-scrolling@angular/cdk/scrolling
rxa-autosize--throttled.pngcdk-autosize--throttled.png

Further Improvementsโ€‹

The following section describes features that are currently not implemented, but planned.

Support other orientationsโ€‹

Right now, the @rx-angular/template/experimental/virtual-scrolling package only supports vertical scrolling. In the future, it should also be able to support horizontal scrolling.

This is currently supported by @angular/cdk/scrolling.

Issue link: https://github.com/rx-angular/rx-angular/issues/1554

Support grid virtual scrollingโ€‹

Displaying items in a list or a grid is a common feature when viewing huge result sets, this is particularly useful for supporting responsive screen sizes, and taking advantage of available real-estate.

Issue link: https://github.com/rx-angular/rx-angular/issues/1550

Support viewport and scrolling element separationโ€‹

Right now, the @rx-angular/template/experimental/virtual-scrolling package only supports the RxVirtualScrollViewportComponent to be the scrolling element. However, there are cases where you want to define a separate scrolling element, e.g. to support window scrolling.

This is currently supported by @angular/cdk/scrolling and is planned for RxAngular as well.

Issue link: https://github.com/rx-angular/rx-angular/issues/1555

Tombstonesโ€‹

Tombstones, skeletons or placeholder templates are a nice way to improve the scrolling performance, especially when the actual views being rendered are heavy and take a long time to create. Especially for the autosized strategy this can increase the visual stability and runtime performance a lot.

The concept is described in the article Complexities of an infinite scroller and visible in the corresponding demo.

Issue link: https://github.com/rx-angular/rx-angular/issues/1556

Resourcesโ€‹

Demos: A showcase for RxVirtualFor A showcase for RxVirtualFor vs. CdkVirtualFor

Blog Posts