Virtual Scrolling with Angular

Yongtze Chi
8 min readSep 8, 2017

--

UPDATE: Virtual Scrolling is now a standard feature in the Angular Framework (version 7). See https://blog.angular.io/version-7-of-angular-cli-prompts-virtual-scroll-drag-and-drop-and-more-c594e22e7b8c for details.

One of the very powerful tools in the Angular framework is the ngFor directive. With a simple snippet of code, one can do magical things with ngFor. Combined with automatic change detection, Angular’s ngFor will automagically render the view based on data elements in an array.

For example, the following code snippet will render the view items that correspond to each data element in the “items” array:

<div *ngFor="let item of items">{{item}}</div>

When we manipulate the array elements, e.g. by adding/removing array elements, the view gets refreshed automagically, with the newly added/removed view item inserted/removed from the right position. Magic.

In order to create a list of menu items, for example, we simply has to prepare the menu options as an array, and feed the array to the ngFor directive in the HTML template.

<div class="list">
<div class="list-item" *ngFor="let item of items">{{item}}</div>
</div>

The code above will produce something like this in the final rendering:

<div class="list">
<div class="list-item">Item 1</div>
<div class="list-item">Item 2</div>
<div class="list-item">Item 3</div>
...
</div>

So far so good, and, we’ve got a very simple list of menu items with few lines of code.

Where’s The Problem?

The code above works very well in most situation, and we can construct many application UI with a simple ngFor. So, in most situation, this is a very good solution, and there’s no problem.

However, sometimes, simple ngFor does not scale well, especially with large number of elements in the array it’s iterating on. This occurs when we can’t easily predict or cap the number of elements in the array. User generated data queried from a database is a good example of this. Let’s say we are querying for a list of products from a product database, we often can’t assume the number of products returned will be small.

Why does this pose as a problem? Well, it’s quite simple. With each item in the array, ngFor will have to render the HTML template once, and with each rendering, the time it takes to display the UI increases. For instance, let’s say it takes about 100 nanosecond to render each item, when there’s 1000 items in the array it’s 100 milliseconds; when there’s 10,000 items, it takes 1 seconds, and so on and so forth.

List with 10,000 items. Rendering time is about 1.3 seconds.

Now, any sane person will tell you that there’s no sense in rendering all 10,000 items because a typical user can’t possible browse through that many items in the UI. It’s just not a valid scenario. Granted, but the problem is real: more data items leads to higher rendering time leads to higher latency. This problem manifests in real applications as sluggishness of UI, high lag, or non responsive UI. Not good.

Window of Data

So, how do we solve the problem? What is the trick that will make ngFor render the list of items faster?

Well, there’s no way to make ngFor render faster. However, we could make ngFor render less items, far less than the total number of elements in the data array, hence reducing rendering time. But, how?

A simple observation of any UI is that we are always limited by how many pixels we have on the screen; how many pixels we can allocate to any UI component determines how much data we can visualize in that UI component. More specifically, a list of 10,000 items cannot be shown all at once on the screen. We normally see a subview or “window” of the entire dataset in the limited screen real estate allocated to the UI component. The rest will not be visible until the user scrolls to the appropriate position.

This means that if we can figure out which items are visible on the screen (based on scroll position), then we can render only those visible items, and ignore the rest. As user scrolls up or down, we’ll refresh the UI by moving the visible window on the data array accordingly.

This is an age old technic used in various UI platforms (even before the web), and sometimes it’s referred to as “virtual scrolling”. The rest of this article will describe an easy way to implement this technic using the Angular framework.

Slicing the Data

To find which data elements in an array that’s in the UI component’s view, we will need the following information:

  1. Height of the List component (height).
  2. Height of each row in the List component (rowHeight).
  3. Current scrolling position (scrollTop).
  4. Number of elements in the array (items.length).

The rowHeight value is defined during component initialization, and the other values can be queried from the List UI’s HTML element once they are created.

Then we calculate the starting index and ending index of the slice of data we’ll need from the original items array that’s in view:

startIndex = Math.floor(scrollTop / rowHeight);endIndex = Math.ceil((scrollTop + height) / rowHeight);

And then, we can slice the data out from the original array:

itemsInView = items.slice(startIndex, endIndex);

Rendering Items in View

Now that we have the slice of data that’s in view, we can render them using a simple ngFor.

<div class="list">
<div class="list-item" *ngFor="let item of itemsInView"
[style.height]="rowHeight + 'px'"
[style.line-height]="rowHeight + 'px'">
{{item}}
</div>
</div>

And the CSS styling for these elements:

.list {
display: inline-block;
position: relative;
width: 400px;
height: 400px;
overflow-x: hidden;
overflow-y: auto;
box-sizing: border-box;
border: 1px solid #e8f8ff;
}
.list-item {
border-bottom: 1px solid #e8f8ff;
background-color: #ffffff;
box-sizing: border-box;
}

Scrolling

So far so good. But, what you will notice if we ran this sample code, the scroll bar has disappeared. This is because, now that we have reduced the number of items being rendered, the entire content fits inside the container div vertically. How do we then allow user to scroll?

There are two options:

  1. Insert a div inside the container div that will have the same height as the original content to force the scroll bar to appear.
  2. Take full control of scrolling by handling mousewheel and keyboard events, in addition to providing our own scrollbar component.

Option #1 is easier to implement, while option #2 will give us more control over the visuals of the scroll bar, as well as the scrolling behavior. But option #2 does take a bit more code to implement.For simplicity sake, we’ll implement option #1 in this article.

First, we insert a “filler” div after the items div.

<div class="list">
...
<div class="list-filler" [style.height]="(items.length * rowHeight) + 'px'"></div>
</div>

And the CSS:

.list-filler {
position: absolute;
top: 0;
right: -1px;
width: 1px;
box-sizing: border-box;
}

We’ll also need to handle the scroll event to update the startIndex and endIndex values.

<div #container class="menu" (scroll)="refresh()">
...
</div>

And, the event handler:

@ViewChild("container") container:any;
@Input() rowHeight:number = 40;
@Input() items:any[];
itemsInView: any[];
startIndex:number = 0;
endIndex:number = 0;
refresh() {
let scrollTop = container.nativeElement.scrollTop;
let height = container.nativeElement.clientHeight;
this.startIndex = Math.floor(scrollTop / this.rowHeight); this.endIndex = Math.ceil((scrollTop + height) / this.rowHeight); if (this.items) {
this.itemsInView = this.items.slice(this.startIndex, this.endIndex);
}
}

By magic of Angular’s data binding mechanism, the UI will be refreshed automatically. Although, there’s one more thing we need to handle.

In order to place the list items div in the correct position, we will be wrapping them inside a view div so that we can set the top position of the view div based on the value of startIndex. This way, the items will be visible in the view.

<div class="list">
<div class="list-view" [style.top]="(startIndex * rowHeight) + 'px'">
...
</div>
<div class="list-filler" [style.height]="(items.length * rowHeight) + 'px'"></div>
</div>

The CSS:

.list-view {
position: absolute;
left: 0;
right: 0;
}

ListComponent

Now that we’ve seen all the pieces in snippets of code, let’s put all that together into a proper component. First, we have the component class (list.component.ts):

import { Component, OnInit, OnChanges, Input, ViewChild, ViewEncapsulation } from '@angular/core';@Component({
selector: 'list',
templateUrl: './list.component.html',
styleUrls: ['./list.component.css'],
encapsulation: ViewEncapsulation.None
})
export class ListComponent implements OnInit, OnChanges {
@ViewChild("container") container:any;
@Input() rowHeight:number = 40;
@Input() items:any[];
itemsInView: any[];
startIndex:number = 0;
endIndex:number = 0;
constructor() { } ngOnInit() {
this.refresh();
}
ngOnChanges(changes) {
this.refresh();
}
refresh() {
let scrollTop = this.container.nativeElement.scrollTop;
let height = this.container.nativeElement.clientHeight;
this.startIndex = Math.floor(scrollTop / this.rowHeight); this.endIndex = Math.ceil((scrollTop + height) / this.rowHeight); if (this.items) {
this.itemsInView = this.items.slice(this.startIndex, this.endIndex);
}
}
}

The HTML template (list.component.html):

<div class="list">
<div class="list-view" [style.top]="(startIndex * rowHeight) + 'px'">
<div class="list-item" *ngFor="item of itemsInView"
[style.height]="rowHeight + 'px'"
[style.line-height]="rowHeight + 'px'">
{{item}}
</div>
</div>
<div class="list-filler" [style.height]="(items.length * rowHeight) + 'px'"></div>
</div>

And the CSS (list.component.css):

.list {
display: inline-block;
position: relative;
width: 400px;
height: 400px;
overflow-x: hidden;
overflow-y: auto;
box-sizing: border-box;
border: 1px solid #e8f8ff;
}
.list-item {
border-bottom: 1px solid #e8f8ff;
background-color: #ffffff;
box-sizing: border-box;
font-family: sans-serif;
font-weight: 300;
font-size: 14px;
padding: 0 20px;
}
.list-view {
position: absolute;
left: 0;
right: 0;
}
.list-filler {
position: absolute;
top: 0;
right: -1px;
width: 1px;
box-sizing: border-box;
}

How Do We Use This?

So far, we have been focusing on the ListComponent itself. In order to instantiate this component, we need to provide a couple things:

  1. Data array (items), required.
  2. Height of each row (rowHeight), defaults to 40 if not set.

Code for app.component.html:

<list [items]="items" rowHeight="40"></list>

Code for app.component.ts:

import { Component } from '@angular/core';
import { ListComponent } from './list/list.component';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
items:string[] = [];
constructor() {
this.items = this.generateItems(10000);
}
generateItems(n:number):string[] {
var items:string[] = [];
for (var i = 1; i <= n; i++) {
items.push('Item ' + i);
}
return items;
}
}

Here we are generating 10,000 items for testing.

Performance

Without virtual scrolling, the total load time is more than 3 secs.

Without virtual scrolling, total load time is 3.1 secs.

With virtual scrolling, the same list with 10,000 items can now be loaded in about 970 ms.

With virtual scrolling, total load time is 970 ms.

Note that the rendering time reduced from 1299 ms to 53 ms. Also notable is that the script execution time reduced from 1351 ms to 660 ms.

Summary

As we can see, using virtual scrolling technic, we can reduce rendering time quite significantly. 2 secs saving may not sound like much, and 10,000 items list may not sound like a common use case. But, in a real world application, we will often have multiple components. Individually, the rendering time for each component may not be much, but collectively, they often add up. If we can reduce rendering time for each component, the time savings will often lead to much more responsive UI.

--

--

Responses (1)