この記事はVoicyエンジニアによる技術ブログリレーの24日目の記事です。
はじめに
こんにちは、株式会社VoicyでWebフロントエンジニアをしているきーくん(@komura_c)です。 今回は、Angularでの無限スクロールの実装を3つ紹介します! 機能の仕様によって色んな実装方法があると思いますが、シンプルな実装を取り上げていきます。
無限スクロールとは
無限スクロールとは、別のページへ遷移しなくてもスクロールダウンによって追加でコンテンツが読み込まれる機能です。 SNSのタイムラインなどが代表的で、コンテンツが多くあるアプリや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というより、ネイティブなJavaScriptのAPIである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も試しました)
また、Voicyでは今月、ブログリレーとしてさまざまな連載をおこなっているので、トップページより他の記事も読んでみてください!
加えて、Voicyのエンジニアチームに少しでも興味をお持ちになった方は、ぜひこちらもご覧ください。 www.wantedly.com