Use RxJs to implement an Angular Component that supports infinite scroll

Posted by djBuilder on Thu, 10 Mar 2022 11:41:04 +0100

First, let's take a look at the runtime effect of my Angular application that supports infinite scroll:

https://jerry-infinite-scroller.stackblitz.io/

Scroll the middle mouse button and scroll down to trigger the list to continuously send requests to the background to load new data:

The following are the specific development steps.

(1) app.component.html source code:

<div>
  <h2>{{ title }}</h2>
  <ul
    id="infinite-scroller"
    appInfiniteScroller
    scrollPerecnt="70"
    [immediateCallback]="true"
    [scrollCallback]="scrollCallback"
  >
    <li *ngFor="let item of news">{{ item.title }}</li>
  </ul>
</div>

Here, we apply a custom instruction appinfinitesqueller to the list element ul, which gives it the function of supporting infinite scroll.

[scrollCallback] = "scrollCallback" this line of statements. The former is the input attribute of custom execution, and the latter is a function defined by app Component, which is used to specify what business logic should be executed when the scroll event of list occurs.

There is an attribute news of type set in app component, which is expanded by the structure instruction ngFor and displayed as a list line item.

(2) Implementation of APP component:

import { Component } from '@angular/core';
import { HackerNewsService } from './hacker-news.service';

import { tap } from 'rxjs/operators';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss'],
})
export class AppComponent {
  currentPage: number = 1;
  title = '';
  news: Array<any> = [];

  scrollCallback;

  constructor(private hackerNewsSerivce: HackerNewsService) {
    this.scrollCallback = this.getStories.bind(this);
  }

  getStories() {
    return this.hackerNewsSerivce
      .getLatestStories(this.currentPage)
      .pipe(tap(this.processData));
    // .do(this.processData);
  }

  private processData = (news) => {
    this.currentPage++;
    this.news = this.news.concat(news);
  };
}

Bind the function getStories to the attribute scrollCallback, so that when the list scroll event occurs, call the getStories function to read the stories data of the new page and merge the results into the array attribute this In news. The logic of reading stories is completed in hackerNewsService.

(3) hackerNewsService is consumed by app Component through dependency injection.

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';

const BASE_URL = 'https://node-hnapi.herokuapp.com';

@Injectable()
export class HackerNewsService {
  constructor(private http: HttpClient) {}

  getLatestStories(page: number = 1) {
    return this.http.get(`${BASE_URL}/news?page=${page}`);
  }
}

(4) The core part is the custom instruction.

import { Directive, AfterViewInit, ElementRef, Input } from '@angular/core';

import { fromEvent } from 'rxjs';

import { pairwise, map, exhaustMap, filter, startWith } from 'rxjs/operators';

interface ScrollPosition {
  sH: number;
  sT: number;
  cH: number;
}

const DEFAULT_SCROLL_POSITION: ScrollPosition = {
  sH: 0,
  sT: 0,
  cH: 0,
};

@Directive({
  selector: '[appInfiniteScroller]',
})
export class InfiniteScrollerDirective implements AfterViewInit {
  private scrollEvent$;

  private userScrolledDown$;

  // private requestStream$;

  private requestOnScroll$;

  @Input()
  scrollCallback;

  @Input()
  immediateCallback;

  @Input()
  scrollPercent = 70;

  constructor(private elm: ElementRef) {}

  ngAfterViewInit() {
    this.registerScrollEvent();

    this.streamScrollEvents();

    this.requestCallbackOnScroll();
  }

  private registerScrollEvent() {
    this.scrollEvent$ = fromEvent(this.elm.nativeElement, 'scroll');
  }

  private streamScrollEvents() {
    this.userScrolledDown$ = this.scrollEvent$.pipe(
      map(
        (e: any): ScrollPosition => ({
          sH: e.target.scrollHeight,
          sT: e.target.scrollTop,
          cH: e.target.clientHeight,
        })
      ),
      pairwise(),
      filter(
        (positions) =>
          this.isUserScrollingDown(positions) &&
          this.isScrollExpectedPercent(positions[1])
      )
    );
  }

  private requestCallbackOnScroll() {
    this.requestOnScroll$ = this.userScrolledDown$;

    if (this.immediateCallback) {
      this.requestOnScroll$ = this.requestOnScroll$.pipe(
        startWith([DEFAULT_SCROLL_POSITION, DEFAULT_SCROLL_POSITION])
      );
    }

    this.requestOnScroll$
      .pipe(
        exhaustMap(() => {
          return this.scrollCallback();
        })
      )
      .subscribe(() => {});
  }

  private isUserScrollingDown = (positions) => {
    return positions[0].sT < positions[1].sT;
  };

  private isScrollExpectedPercent = (position) => {
    return (position.sT + position.cH) / position.sH > this.scrollPercent / 100;
  };
}

First, define a ScrollPosition interface, which contains three fields sH , sT and cH, respectively, maintain the three fields of the scroll event object: scrollHeight, scrollTop and clientHeight

We construct a scrollEvent Observable object from the scrollEvent of the dom element to which the custom instruction is applied. In this way, when the scroll event occurs, the scrollEvent will automatically emit the event object.

Because we are not interested in most of the attribute information of this event object, we use map to map the scroll event object into three fields we are only interested in: scrollHeight, scrollTop and clientHeight:

However, with only the data of these three points, we cannot determine the scroll direction of the current list.

So use pairwise, the operator provided by rxjs, to put the coordinates generated every two clicks into an array, and then use the function this Isuserscrollingdown to determine the direction of the current user scroll.

If the scrollTop of the latter element is larger than the previous element, it indicates that it is scrolling down:

  private isUserScrollingDown = (positions) => {
    return positions[0].sT < positions[1].sT;
  };

We don't immediately trigger an HTTP request to load the data of the next page when we detect that the current user scroll s down, but we have to exceed a threshold.

The implementation logic of this threshold is as follows:

private isScrollExpectedPercent = (position) => {
    console.log('Jerry position: ', position);
    const reachThreshold =
      (position.sT + position.cH) / position.sH > this.scrollPercent / 100;
    const percent = ((position.sT + position.cH) * 100) / position.sH;
    console.log('reach threshold: ', reachThreshold, ' percent: ', percent);
    return reachThreshold;
  };

As shown in the following figure: when the threshold reaches 70, it returns true: