📘

Angular CDKで可変サイズや逆方向の仮想スクロールを実装するOSSを作った話

2024/07/30に公開

タイトルのままですが、 @rdlabo/ngx-cdk-scroll-strategies をOSSとしてリリースしました。 Angular公式では

  • 固定サイズの仮想スクロール: FixedSizeVirtualScrollStrategy
  • 可変サイズの仮想スクロール: AutoSizeVirtualScrollStrategy(Experimental)

のふたつが用意されています。固定サイズは(UI設計が固定サイズにできる場合は)問題なく使えるのですが、私の環境では可変サイズはスクロールがカクついたりジャンプしたりとうまく動かなかったんですよね。またソースコードを読んだところ、アイテムサイズを個別に持つのではなく、全体の平均サイズとして保持するようになっているので、大きなサイズのアイテムを削除したらスクロールがおかしくなる問題もあったり。

https://github.com/angular/components/blob/main/src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts#L49-L59

あと、どちらもLINEやWeChatのような逆スクロールはサポートされていませんでした。なので、プロダクトでは表示数を制限しながら数年間仮想スクロールなしで運用していたのですが、その制限をなくしたいと思って、 @rdlabo/ngx-cdk-scroll-strategies を作成しました。

コンセプト: CdkDynamicSizeVirtualScroll

すべてが同一のアイテムサイズになる FixedSizeVirtualScrollStrategy と、アイテムサイズが不明な AutoSizeVirtualScrollStrategy の中間を狙って、「すべてのアイテムサイズを指定する」仮想スクロールです。コンセプトコードは以下の通りです。

<cdk-virtual-scroll-viewport [itemDynamicSizes]="[{ itemSize: 100 } , { itemSize: 80} , { itemSize: 90 } , { itemSize: 100}]">
  <div *cdkVirtualFor="let item of [100, 80, 90, 100]; trackBy: trackByFn" [style.height.px]="item">
    itemSize: {{ item }}
  </div>
</cdk-virtual-scroll-viewport>

仮想スクロールのアイテム順と itemDynamicSizes の順序は一致している必要があります。

ですので、別々にソートするなどするより、アイテムをSignalsで管理して、itemDynamicSizes に渡す値はアイテムをComputedで変換するのがおすすめです。 @rx-angular/template のように仮想スクロールのすべてを再実装するのではなく、 @angular/cdkCdkVirtualScrollViewport に対してこのライブラリを追加することで、既存のコードに対して容易に導入できるようにしました。

デモ

以下で、「Simple」「Advanced」「Reverse」の3つのデモを用意しました。実際にさわって、開発者ツールからDOMの様子を観察してください。

https://rdlabo-ionic-angular-library.netlify.app/main/scroll-strategies

インストール

% npm install @rdlabo/ngx-cdk-scroll-strategies

使い方

シンプルな使い方

一番シンプルな使い方です。Angular CDKの CdkVirtualScrollViewport に対して、 CdkDynamicSizeVirtualScroll を追加し、 itemDynamicSizes にアイテムサイズを指定します。

import { CdkDynamicSizeVirtualScroll, itemDynamicSize } from '@rdlabo/ngx-cdk-scroll-strategies';

@Component({
  ...
  imports: [
    CdkDynamicSizeVirtualScroll
  ],
})
export class ScrollStrategiesPage implements OnInit {
  readonly items = signal<itemDynamicSize[]>([]);
  readonly dynamicSize = computed<itemDynamicSize[]>(() => {
    return this.items().map((item) => ({ trackId: item.trackId, itemSize: item.itemSize }));
  });
}
<cdk-virtual-scroll-viewport [itemDynamicSizes]="dynamicSize()" minBufferPx="900" maxBufferPx="1350">
  <div *cdkVirtualFor="let item of items(); trackBy: trackByFn" class="dynamic-item" [style.height.px]="item.itemSize">
    itemSize: {{ item.itemSize }}
  </div>
</cdk-virtual-scroll-viewport>

高度な使い方

ただシンプルな使い方では実プロダクトへの導入がしんどいので、高度なサンプルも用意しています。 CdkVirtualScrollViewport ってよくできてて、切り出したコンポーネントを cdkVirtualFor でループしていると、スクロール時にDOMを破棄せず、再利用してスクロールしたりするんですよね。なので、コンポーネント毎に実アイテムサイズを計測して取得する時は、親からではなくコンポーネント側で(Inputの値が変わるごとに)計測する必要があったりします。

https://github.com/rdlabo-team/ionic-angular-library/blob/main/projects/demo/src/app/scroll-strategies/components/scroll-advanced-item/scroll-advanced-item.component.ts#L20-L38

また、都度アイテムサイズを反映すると計算量が多くなりすぎるので、「スクロールして、表示アイテムが変更されたタイミングで、新しい実測値があれば反映する等の工夫が必要です。

https://github.com/rdlabo-team/ionic-angular-library/blob/main/projects/demo/src/app/scroll-strategies/pages/scroll-advanced/scroll-advanced.page.ts#L104-L112

ほぼ私が実プロダクトで使ってるコードに準じています。流し読みするとしんどいので、レポジトリをローカルにcloneして、IDEの機能でジャンプしながら読む方がわかりやすいのではないかなと思います。Angularの基本的な知識は必要です。

逆スクロールの使い方

LINEやWeChatのような逆スクロールで仮想スクロールを実装する方法です。これを実装する場合、必ず先にFlexboxの flex-direction: column-reverse;flex-direction: column; を使ってスクロール計算を上下逆にする方法を学ぶようにしてください。以下のサンプルはとてもわかりやすいです。

https://codesandbox.io/s/flex-column-reverse-scroll-properties-i810t?file=/index.html:829-852

本ディレクティブの逆スクロールはこれをベースに実装しています。

逆スクロールを行う場合は、 isReversetrue に設定してください。また、逆スクロールの場合は、 cdkVirtualFor でループさせるアイテムのラッパーDOM(以下では、 div.reverse-items )を用意する必要があります。

<cdk-virtual-scroll-viewport [itemDynamicSizes]="dynamicSize()" [isReverse]="true" minBufferPx="900" maxBufferPx="1350">
  <div class="reverse-items">
    <div *cdkVirtualFor="let item of items(); trackBy: trackByFn" class="dynamic-item" [style.height.px]="item.itemSize">
      itemSize: {{ item.itemSize }}
    </div>
  </div>
</cdk-virtual-scroll-viewport>

また、 cdk-virtual-scroll-viewportflex-direction: column-reverse; を追加する必要があります。 isReversetrue にすると自動的に reverse-scroll クラスが付与されるので、グローバルなCSS(styles.css等)に、以下を追加してください。

cdk-virtual-scroll-viewport {
  width: 100%;
  height: 100%;

  // .reverse-scroll class is added from this directive.
  &.reverse-scroll {
    display: flex;
    flex-direction: column-reverse;

    .cdk-virtual-scroll-content-wrapper {
      top: auto;
      bottom: 0;
    }
  }
}

また、用意したラッパーDOM(ここでは、 div.reverse-items )にもスタイルをあてる必要があります。

div.reverse-items {
  height: 100%;
  display: flex;
  flex-direction: column-reverse;

  position: relative;
  bottom: 0;
}

まとめ

本ライブラリを使うと、 CdkVirtualScrollViewport に対して、個別のアイテムサイズを指定することで、可変サイズや逆スクロールを実現することができるようになります。ぜひ、お試しください。
それでは、また。

Discussion