The @angular/cdk/scrolling
module with a technique called Virtual Scrolling helps us display a big list of elements efficiently by only rendering the items in view. Virtual Scrolling is different from infinite scroll – where it renders batches of elements and then when user goes to bottom of the list, it renders the rest. In this tutorial, we create many simple examples that show you how to work with Angular 7 Material CDK – Virtual Scrolling.
Related Post: Angular 7 Drag and Drop example – Angular Material CDK
Setup Angular 7 Material CDK
– Run cmd:
npm install @angular/cdk
– Import ScrollingModule
into NgModule
:
...
import { ScrollingModule } from '@angular/cdk/scrolling';
@NgModule({
declarations: [...],
imports: [
...
ScrollingModule
],
...
})
export class AppModule { }
Angular 7 Virtual Scroll
Generate a list of 1000 random number items:
items = Array(1000).fill(0).map(() => Math.round(Math.random() * 100));
Create Viewport
We use <cdk-virtual-scroll-viewport>
directive with *cdkVirtualFor
inside (loop same as *ngFor
).
To indicate the size of each item (it maybe height or width depending on the orientation of the Viewport), we use itemSize
input property.
<cdk-virtual-scroll-viewport class="gkz-viewport" [itemSize]="20"> <div *cdkVirtualFor="let item of items; let i = index" class="item"> Item #{{i}}: {{item}} </div> </cdk-virtual-scroll-viewport>
Style with .cdk-virtual-scroll-content-wrapper
(wrapper element that contains the rendered content):
.gkz-viewport { height: 200px; width: 200px; border: 1px solid black; .cdk-virtual-scroll-content-wrapper { display: flex; flex-direction: column; } .item { height: 19px; border-bottom: 1px dashed #aaa; } }
Use Context Variables
With *cdkVirtualFor
, we have some context variables: index
, count
, first
, last
, even
, odd
.
<cdk-virtual-scroll-viewport class="gkz-viewport" [itemSize]="20*6"> <div [class.item-alternate]="odd" *cdkVirtualFor=" let item of items; let i = index; let count = count; let first = first; let last = last; let even = even; let odd = odd;"> <div class="item">Item #{{i}}: {{item}}</div> <div class="item">count: {{count}}</div> <div class="item">first: {{first}}</div> <div class="item">last: {{last}}</div> <div class="item">even: {{even ? 'Yes' : 'No'}}</div> <div class="item">odd: {{odd ? 'Yes' : 'No'}}</div> </div> </cdk-virtual-scroll-viewport>
With item-alternate
style:
.gkz-viewport { ... .item-alternate { background: #ddd; } }
Reduce memory on Caching
By default, *cdkVirtualFor
caches created items to improve rendering performance.
We can adjusted size of the view cache using templateCacheSize
property (0 means disabling caching).
<cdk-virtual-scroll-viewport class="gkz-viewport" [itemSize]="20"> <div *cdkVirtualFor="let item of items; let i = index; templateCacheSize: 0" class="item"> Item #{{i}}: {{item}} </div> </cdk-virtual-scroll-viewport>
Horizontal Viewport
By default, the orientation of Viewport is vertical, we can change it by setting orientation="horizontal"
.
Notice that, instead of height, itemSize
property in horizontal orienttation specifies width.
<cdk-virtual-scroll-viewport orientation="horizontal" itemSize="50" class="gkz-horizontal-viewport"> <div *cdkVirtualFor="let item of items; let i = index" class="item"> Item #{{i}}: {{item}} </div> </cdk-virtual-scroll-viewport>
We also need to target CSS at .cdk-virtual-scroll-content-wrapper
(the wrapper element contains the rendered content).
.gkz-horizontal-viewport { height: 120px; width: 200px; border: 1px solid black; .cdk-virtual-scroll-content-wrapper { display: flex; flex-direction: row; } .item { width: 48px; height: 100px; border: 1px dashed #aaa; display: inline-flex; justify-content: center; align-items: center; writing-mode: vertical-lr; } }
Custom Buffer Parameters
When using itemSize
directive for fixed size strategy, we can set 2 more buffer parameters: minBufferPx
& maxBufferPx
that calculate the extra content to be rendered beyond visible items in the Viewport:
- if the Viewport detects that there is less buffered content than minBufferPx
it will immediately render more.
- the Viewport will render at least enough buffer to get back to maxBufferPx
.
For example, we set: itemSize = 20
, minBufferPx = 50
, maxBufferPx = 200
.
The viewport detects that buffer remaining is only 40px
(2 items).
- 40px
< minBufferPx
: render more buffer.
- render an additional 160px
(8 items) to bring the total buffer size to 40px + 160px = 200px
>= maxBufferPx
.
Angular 7 Virtual Scroll with Specific Data
As well as Array
, *cdkVirtualFor
can work with DataSource
or Observable<Array>
.
DataSource
A DataSource
is an abstract class with two methods:
- connect()
: is called by the Viewport to receive a stream that emits the data array.
- disconnect()
: will be invoked when the Viewport is destroyed.
In this example, we will create a DataSource
with 1000
items, and simulate fetching data from server using setTimeout()
function.
import { BehaviorSubject, Observable, Subscription } from 'rxjs'; import { DataSource, CollectionViewer } from '@angular/cdk/collections'; ... export class SpecDataComponent { dataSource = new MyDataSource(); } export class MyDataSource extends DataSource<string | undefined> { private PAGE_SIZE = 10; private fetchedPages = new Set<number>(); private cachedData = Array.from<string>({ length: 1000 }); private dataStream = new BehaviorSubject<(string | undefined)[]>(this.cachedData); private subscription = new Subscription(); connect(collectionViewer: CollectionViewer): Observable<(string | undefined)[]> { this.subscription.add(collectionViewer.viewChange.subscribe(range => { const startPage = this.getPageForIndex(range.start); const endPage = this.getPageForIndex(range.end); for (let i = startPage; i <= endPage; i++) { this.fetchPage(i); } })); return this.dataStream; } disconnect(): void { this.subscription.unsubscribe(); } private getPageForIndex(index: number): number { return Math.floor(index / this.PAGE_SIZE); } private fetchPage(page: number) { if (this.fetchedPages.has(page)) { return; } this.fetchedPages.add(page); // simulate fetching data from server setTimeout(() => { this.cachedData.splice(page * this.PAGE_SIZE, this.PAGE_SIZE, ...Array.from({ length: this.PAGE_SIZE }) .map((_, i) => `Item #${page * this.PAGE_SIZE + i}`)); this.dataStream.next(this.cachedData); }, 2000); } }
Iterating DataSource
by *cdkVirtualFor
is just like working with an Array
:
<cdk-virtual-scroll-viewport itemSize="20" class="gkz-viewport"> <div *cdkVirtualFor="let item of dataSource" class="item">{{item || 'Loading...'}}</div> </cdk-virtual-scroll-viewport>
Observable
We create an BehaviorSubject
that emit an Array
everytime user clicks on Button:
observableData = new BehaviorSubject<number[]>([]); emitData() { const items = Array(3).fill(0).map(() => Math.round(Math.random() * 100)); const data = this.observableData.value.concat(items); this.observableData.next(data); }
Iterating Observable Data by *cdkVirtualFor
is just like working with an Array
:
<button (click)="emitData()">Add item</button> <cdk-virtual-scroll-viewport class="gkz-viewport" [itemSize]="20"> <div *cdkVirtualFor="let item of observableData | async; let i = index" class="item"> Item #{{i}}: {{item}} </div> </cdk-virtual-scroll-viewport>
Angular 7 Virtual Scroll trackBy
We create an Observable data that will be changed (sorted):
import { BehaviorSubject } from 'rxjs'; interface Customer { id: number; name: string; } export class TrackbyComponent { customers = [ { id: 1, name: 'John Bailey' }, { id: 2, name: 'Amelia Kerr' }, { id: 3, name: 'Julian Wallace' }, { id: 4, name: 'Pippa Sutherland' }, { id: 5, name: 'Stephanie Simpson' }, ... ]; customersObservable = new BehaviorSubject(this.customers); sortBy(prop: 'id' | 'name') { this.customersObservable.next(this.customers.map(s => ({ ...s })).sort((a, b) => { const aProp = a[prop], bProp = b[prop]; if (aProp < bProp) { return -1; } else if (aProp > bProp) { return 1; } return 0; })); } }
To check trackBy
, we will test 3 cases:
- no trackBy
function
- trackBy
index
- trackBy
name field
No trackBy function
<button (click)="sortBy('id')">Sort by id</button> <button (click)="sortBy('name')">Sort by name</button> <cdk-virtual-scroll-viewport class="gkz-viewport" [itemSize]="20"> <div *cdkVirtualFor="let customer of customersObservable | async" class="customer-item"> {{customer.id}} - {{customer.name}} </div> </cdk-virtual-scroll-viewport>
With trackBy function
trackBy
function works the same as the *ngFor trackBy
.
indexTrackFn = (index: number) => index; nameTrackFn = (_: number, item: Customer) => item.name;
trackBy index
<button (click)="sortBy('id')">Sort by id</button> <button (click)="sortBy('name')">Sort by name</button> <cdk-virtual-scroll-viewport class="gkz-viewport" [itemSize]="20"> <div *cdkVirtualFor="let customer of customersObservable | async; trackBy: indexTrackFn" class="customer-item"> {{customer.id}} - {{customer.name}} </div> </cdk-virtual-scroll-viewport>
trackBy a field
<button (click)="sortBy('id')">Sort by id</button> <button (click)="sortBy('name')">Sort by name</button> <cdk-virtual-scroll-viewport class="gkz-viewport" [itemSize]="20"> <div *cdkVirtualFor="let customer of customersObservable | async; trackBy: nameTrackFn" class="customer-item"> {{customer.id}} - {{customer.name}} </div> </cdk-virtual-scroll-viewport>
Angular 7 Virtual Scroll Strategy
Instead of using the itemSize
directive on the Viewport, we can provide a custom strategy by creating a class that implements the VirtualScrollStrategy
interface and providing it as the VIRTUAL_SCROLL_STRATEGY
on the @Component
.
import { Component, ChangeDetectionStrategy } from '@angular/core'; import { FixedSizeVirtualScrollStrategy, VIRTUAL_SCROLL_STRATEGY } from '@angular/cdk/scrolling'; export class CustomVirtualScrollStrategy extends FixedSizeVirtualScrollStrategy { constructor() { super(20, 50, 200); // (itemSize, minBufferPx, maxBufferPx) } } @Component({ selector: 'app-scrolling-strategy', templateUrl: './scrolling-strategy.component.html', styleUrls: ['./scrolling-strategy.component.scss'], providers: [{ provide: VIRTUAL_SCROLL_STRATEGY, useClass: CustomVirtualScrollStrategy }] }) export class ScrollingStrategyComponent { items = Array(1000).fill(0).map(() => Math.round(Math.random() * 100)); }
In the example, our custom strategy class extends FixedSizeVirtualScrollStrategy
that implements VirtualScrollStrategy
.
<cdk-virtual-scroll-viewport class="gkz-viewport"> <div *cdkVirtualFor="let item of items; let i = index" class="item"> Item #{{i}}: {{item}} </div> </cdk-virtual-scroll-viewport>