DEV Community

loizenai
loizenai

Posted on

Angular 7 Virtual Scroll example - Angular Material CDK

https://grokonez.com/frontend/angular/angular-7/angular-7-virtual-scroll-example-angular-material-cdk

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

angular-virtual-scroll-example-create-viewport

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

angular-virtual-scroll-example-custom-variables

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

angular-virtual-scroll-example-horizontal-viewport

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-virtual-scroll-example-custom-buffer-params

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>

angular-virtual-scroll-example-datasource

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-virtual-scroll-example-observable-data

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>

angular-virtual-scroll-example-no-trackby

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>

angular-virtual-scroll-example-trackby-index

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-virtual-scroll-example-trackby-field

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>

angular-virtual-scroll-example-scroll-strategy

Source Code

AngularMaterialScrolling

Top comments (0)