Angular 無限スクロール実装 3選

この記事はVoicyエンジニアによる技術ブログリレーの24日目の記事です。

はじめに

こんにちは、株式会社VoicyでWebフロントエンジニアをしているきーくん(@komura_c)です。 今回は、Angularでの無限スクロールの実装を3つ紹介します! 機能の仕様によって色んな実装方法があると思いますが、シンプルな実装を取り上げていきます。

無限スクロールとは

無限スクロールとは、別のページへ遷移しなくてもスクロールダウンによって追加でコンテンツが読み込まれる機能です。 SNSのタイムラインなどが代表的で、コンテンツが多くあるアプリやWebサービスに採用されていることが多いです。 Voicyにおいても、アプリのフォロー中タブやWebのVoicyのトップページに使用されています。

WebのVoicyのトップページ

1. HostlistenerデコレータとDOCUMENTのDIを使う実装

1つ目は、HostlistenerデコレータとDOCUMENTのDIを使う実装です。

この実装はスクロールする要素が、HTML文書の最上位のビューポート(表示領域)Documentである場合に使うことが多いと思います。

Hostlistenerデコレータとは、指定したDOMイベントが発生した時に呼び出される関数を設定できるデコレータです。ライブラリを使わないネイティブなJavaScriptに存在するaddEventListenerと似た機能が提供されています。Hostlistenerデコレータの公式ドキュメント

DOCUMENTは、DOMのDocumentをAngularで扱うためのDIトークンです。 DOCUMENTの公式ドキュメント

実装は以下の通りです。

sample.component.ts

...
export class SampleComponent {
...
  constructor(
    // DOCUMENT
    @Inject(DOCUMENT) private document: Document,
  ) {}
...
  // Hostlistenerデコレータ
  @HostListener('window:scroll')
  onScroll() {
    // 残りのスクロール量を計算
    const maxScroll = this.document.documentElement.scrollHeight;
    const screenHeight = this.document.documentElement.clientHeight;
    const scrollTop = this.document.documentElement.scrollTop;
    const leftScroll = maxScroll - screenHeight - scrollTop;
...
    // ローディングが完了していて、残りのスクロール量が一定量以下なら次をロード
    if (!this.isLoading && leftScroll < 1000) {
      this.onLoadNext();
    }
  }
...
  onLoadNext() {
     // 次のロード時にする処理
  }

2. イベントバインディングとテンプレート変数を使う実装

2つ目は、イベントバインディングとテンプレート変数を使う実装です。

この実装はスクロールする要素が、特定の要素やコンポーネントである場合に使うことが多いと思います。

イベントバインディングとは、Angularにおけるテンプレート(HTML)の要素からのイベントを受け取ることができるものです。標準的なDOMイベントの他にも独自のカスタムイベントを使うこともできます。イベントバインディングの公式ドキュメント

テンプレート変数とは、テンプレートのタグ内にハッシュ記号「#」を使ってテンプレート変数を宣言し、テンプレート内の任意の場所で参照できるものです。テンプレート変数の公式ドキュメント

実装は以下の通りです。

sample.component.html

<!-- スクロールする要素にscrollイベントをバインド、そのHTML要素をテンプレート変数として宣言、関数に引数として渡す -->
<div #scrollElem (scroll)="onScroll(scrollElem)">
  <app-sample-list [dataList]="dataList"></app-sample-list>
</div>

sample.component.ts

...
export class SampleComponent {
...
  onScroll(scrollElem: HTMLElement) {
    // 渡されたHTML要素を使って、残りのスクロール量を計算
    const maxScroll = scrollElem.scrollHeight;
    const screenHeight = scrollElem.clientHeight;
    const scrollTop = scrollElem.scrollTop;
    const leftScroll = maxScroll - screenHeight - scrollTop;

    // ローディングが完了していて、残りのスクロール量が一定量以下なら次をロード
    if (!this.isLoading && leftScroll < 1000) {
      this.onLoadNext();
    }
  }
...
  onLoadNext() {
     // 次のロード時にする処理
  }

3. Intersection Observer(とViewChildデコレータ、イベントバインディング)を使う実装

3つ目は、Intersection Observer(とViewChildデコレータ、イベントバインディング)を使う実装です。

この実装はAngularというより、ネイティブなJavaScriptAPIであるIntersection Observerが主となります。

Intersection Observer(交差オブザーバー)とは、ターゲット要素が、祖先要素または文書の最上位のビューポートと交差する変化を非同期的に監視する方法を提供するAPIです。Intersection ObserverのMDNドキュメント

このIntersection Observerはスクロール位置によって見た目、スタイル(CSS)を変化させたり、仮想スクロール(コンテンツが多大なスクロール要素に対してパフォーマンスを最適化するもの、AngularMaterialのCDKからも実装用のAPIが提供されている)を実装したりと様々な使い方ができるAPIです。

ViewChildデコレータとは、DOM 内のセレクターに一致する最初の要素またはディレクティブを探すAPIです。ネイティブなJavaScriptに存在するgetElementByIdなどと似た機能が提供されています。また、ViewChildデコレータはディレクティブや子のコンポーネントも参照することができます。ViewChildデコレータの公式ドキュメント

今回の実装は以下の通りです。

sample-list.component.html

<!-- スクロールするリストのアイテムの下に要素を置く -->
<div class="sample-list">
    <div *ngFor="let item of items: trackBy: trackByFn">
    ....
    </div>
   <div #bottomArea></div>
</div>

sample-list.component.ts

...
export class SampleListComponent implements AfterViewInit {
...
  // 親コンポーネントに渡す、下までスクロールが達したというカスタムイベントを定義
  @Output() reachBottomEvent = new EventEmitter();
  // ViewChildデコレータ
  @ViewChild('bottomArea') private bottomAreaRef:
    | ElementRef<HTMLElement>
    | undefined;
  // intersectionObserverをunobserveできるようにコンポーネントで保持
  private intersectionObserverRef: IntersectionObserver | undefined;

  // テンプレートが描画された後にViewChildが使えるため、その後の時点でIntersectionObserverを設置する
  ngAfterViewInit(): void {
    if (this.bottomAreaRef) {
      const options = {
        root: null,
        rootMargin: '50% 0px',
        threshold: 0,
      };
      this.intersectionObserverRef = new IntersectionObserver((entries) => {
        entries.forEach((entry: any) => {
          if (!entry.isIntersecting) return;
          this.reachBottomEvent.emit();
        });
      }, options);
      this.intersectionObserverRef.observe(this.bottomAreaRef.nativeElement);
    }
  }

  ngOnDestroy(): void {
    if (this.intersectionObserverRef && this.bottomAreaRef) {
      this.intersectionObserverRef.unobserve(this.bottomAreaRef.nativeElement);
    }
  }

sample.component.html

<!-- 親コンポーネントのHTMLで実行する関数を定義 -->
<div>
  <app-sample-list [dataList]="dataList" (reachBottomEvent)="onLoadNext()"></app-sample-list>
</div>

おわりに

今回は、Angularで無限スクロールを実装する方法を3つ紹介しました。 実際はより実装が複雑になることが多いと思いますが、何か参考になれば嬉しいです。

実装したサンプルは、記事中のコードの表記と若干の相違がありますが、雑にGithub上で公開しています。 興味があれば、ご覧ください。(ついでにstandalone componentも試しました)

github.com

また、Voicyでは今月、ブログリレーとしてさまざまな連載をおこなっているので、トップページより他の記事も読んでみてください!

加えて、Voicyのエンジニアチームに少しでも興味をお持ちになった方は、ぜひこちらもご覧ください。 www.wantedly.com