【Angular Material】ツリーをバーチャルスクロール

# はじめに

メリークリスマス！
本記事は[ゆるプロ▼ Advent Calendar 2023](https://qiita.com/advent-calendar/2023/yurupro)の24日目担当になります。

## 前振り

さて、クリスマスといえば何でしょうか？
ケーキ？　チキン？　サンタクロース？
色々ありますよね。でも街中で見かけるクリスマスといえば？
そう、クリスマスツリーです！
ツリー。

プログラミングにもtreeはあります。
木構造とか、ディレクトリ構造とか。

そんなツリー構造をUIで表現しようと思った時、[Angular Material](https://material.angular.io/)なら[Tree](https://material.angular.io/components/tree/overview)があります。

でもこれって閉じられている部分が画面に映っていない部分も全てDOMが作成されているんですよね、、、
ツリーのノード数が増えたら画面全体が重くなりそう、、、

なんて思ってたらAngular Material CDKには[scrolling](https://material.angular.io/cdk/scrolling/overview)にてバーチャルスクロールが対応していました！
バーチャルスクロールについては各自調べてください。

でもAngular Material CDKのバーチャルスクロールは一番上のノードしか見てくれません。
[Tree](https://material.angular.io/components/tree/overview)を用いていて、もし上位ノードが少なくて子ノードが多いツリーだとあまり恩恵を得られなさそう・・・

というわけで今回はバーチャルスクロールをツリー表示で行う方法です！
Angular v17で行います。

# 今回用いるデータ

下記Rubyプログラムで今回使うデータを生成します。
`PATH` を好きな場所に書き換えてください。

UUIDは被らないこと前提に書いているので注意ください。

```ruby
require 'json'
require 'securerandom'

PATH = "/path/to/data.json"

File.open(PATH, "w") do |file|
    data = (1..50).map do |i|
        children = []
        if i % 2 == 0 then
            children = (1..10).map do |j|
                c = []
                if j % 2 == 0 then
                    c = (1..5).map do |k|
                        {"id" => SecureRandom.uuid, "name" => "名前#{i}_#{j}_#{k}", "children" => c}
                    end
                end
                {"id" => SecureRandom.uuid, "name" => "名前#{i}_#{j}", "children" => c}
            end
        end
        {"id" => SecureRandom.uuid, "name" => "名前#{i}", "children" => children}
    end

    data[0]["children"] = [
        {
            "id" => SecureRandom.uuid,
            "name" => "名前1_1",
            "children" => [
                {
                    "id" => SecureRandom.uuid,
                    "name" => "名前1_1_1",
                    "children" => [
                        {
                            "id" => SecureRandom.uuid,
                            "name" => "名前1_1_1_1",
                            "children" => []
                        },
                        {
                            "id" => SecureRandom.uuid,
                            "name" => "名前1_1_1_2",
                            "children" => []
                        }
                    ]
                },
                {
                    "id" => SecureRandom.uuid,
                    "name" => "名前1_1_2",
                    "children" => [
                        {
                            "id" => SecureRandom.uuid,
                            "name" => "名前1_1_2_1",
                            "children" => []
                        },
                        {
                            "id" => SecureRandom.uuid,
                            "name" => "名前1_1_2_2",
                            "children" => []
                        }
                    ]
                }
            ]
        },
        {
            "id" => SecureRandom.uuid,
            "name" => "名前1_2",
            "children" => [
                {
                    "id" => SecureRandom.uuid,
                    "name" => "名前1_2_1",
                    "children" => [
                        {
                            "id" => SecureRandom.uuid,
                            "name" => "名前1_2_1_1",
                            "children" => []
                        },
                    ]
                },
                {
                    "id" => SecureRandom.uuid,
                    "name" => "名前1_2_2",
                    "children" => []
                }
            ]
        }
    ]

    JSON.dump(data, file)
end
```

# 実装

上記で生成したJSONをそのままimportする場合はtsconfigの `resolveJsonModule` をtrueにしておいてください。

## HTML

```html
<div class="viewport">
  <cdk-virtual-scroll-viewport itemSize="50" style="height: 100%;">
    <ng-container *cdkVirtualFor="let node of flattenData; templateCacheSize: 0">
      <div [style.padding-left]="node.level * 32 + 'px'" class="list-item">
        <div>{{ node.name }}</div>

        @if (node.hasChild) {
          <button [attr.aria-label]="'toggle ' + node.id" (click)="changeExpand(node)" mat-icon-button>
            <mat-icon class="mat-icon-rtl-mirror">
              {{ node.expanded ? 'expand_more' : 'chevron_right' }}
            </mat-icon>
          </button>
        }
      </div>
    </ng-container>
  </cdk-virtual-scroll-viewport>
</div>
```

## SCSS

```css
.viewport {
  margin: 24px;
  height: 500px;
  border: 1px solid black;
}

.list-item {
  display: flex;
  flex-direction: row;
  align-items: center;
}
```

## TypeScript

```ts
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { ScrollingModule } from '@angular/cdk/scrolling';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';

import data from './data.json';

/** 元データ型(上記JSONの型) */
interface NestedObjType {
  id: string;
  name: string;
  children: NestedObjType[];
}

/** 拡張データ */
interface NestedObjExpandType {
  id: string;
  name: string;
  parentId?: string;
  expanded: boolean;
  hasChild: boolean;
  children: NestedObjExpandType[];
}

/** フラットデータ */
interface FlatObjType {
  id: string;
  name: string;
  parentId?: string;
  expanded: boolean;
  hasChild: boolean;
  level: number;
}

@Component({
  selector: 'app-virtual-scroll-tree',
  standalone: true,
  imports: [
    ScrollingModule,
    MatButtonModule,
    MatIconModule,
  ],
  templateUrl: './virtual-scroll-tree.component.html',
  styleUrl: './virtual-scroll-tree.component.scss',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VirtualScrollTreeComponent {
  /** オリジナル */
  private readonly originalData: NestedObjType[] = data;
  /** 拡張 */
  protected expandData: NestedObjExpandType[];
  /** フラット */
  protected flattenData: FlatObjType[];

  constructor() {
    this.expandData = this.makeExpand(this.originalData);
    this.flattenData = this.updateVirtualScrollList();
  }

  /** 開閉 */
  changeExpand(target: FlatObjType): void {
    const parentSet = new Set<string>([target.id]);
    this.ExpandChange(target, this.expandData, parentSet);
    this.flattenData = this.updateVirtualScrollList();
  }

  /**
   * 開閉変更
   * @param target 選択されたノード
   * @param nodes 変更対象配列
   * @param parentSet 閉じられている親組織一覧
   */
  private ExpandChange(target: FlatObjType, nodes: NestedObjExpandType[], parentSet: Set<string>): void {
    if (nodes.length === 0) {
      return;
    }

    nodes.forEach((item) => {
      // 対象ノードの元データを開閉
      if (item.id === target.id) {
        item.expanded = !item.expanded;
      }
      // 閉じる場合
      if (target.expanded) {
        // 親組織を持っていれば対象組織の子組織も閉じる
        if (item.parentId && parentSet.has(item.parentId)) {
          item.expanded = false;
          parentSet.add(item.id);
        }
      }
      this.ExpandChange(target, item.children, parentSet);
    })
  }

  /** 拡張階層構造化 */
  private makeExpand(
    target: NestedObjType[],
    parent: NestedObjType | null = null
  ): NestedObjExpandType[] {
    if (target.length === 0) {
      return [];
    }
    return target.map((item) => ({
      id: item.id,
      name: item.name,
      parentId: parent?.id,
      expanded: false,
      hasChild: item.children.length > 0,
      children: this.makeExpand(item.children, item),
    }));
  }

  /** フラット化 */
  private makeFlat(
    target: NestedObjExpandType,
    level = 0
  ): FlatObjType[] {
    if (target.children.length === 0) {
      return [{
        id: target.id,
        name: target.name,
        parentId: target.parentId,
        expanded: target.expanded,
        hasChild: target.hasChild,
        level: level
      }];
    }
    return [
      {
        id: target.id,
        name: target.name,
        parentId: target.parentId,
        expanded: target.expanded,
        hasChild: target.hasChild,
        level: level
      },
      ...target.children.flatMap((item) => this.makeFlat(item, level+1))
    ];
  }

  /** バーチャルスクロールリストを再構成 */
  private updateVirtualScrollList(): FlatObjType[] {
    // 親が閉じている組織を全て除く
    const parentSet = new Set<string>();
    return this.expandData
      .flatMap((item) => this.makeFlat(item))
      .filter((item) => {
        if (!item.expanded) {
          parentSet.add(item.id);
        }
        return item.parentId === undefined || !parentSet.has(item.parentId);
      });
  }
}
```

# 解説

方針として、[MatTree](https://material.angular.io/components/tree/overview)が使えないためフラットなリスト構造で持ちつつ、左側にpaddingを持たせて階層構造を表現します。

## HTML, SCSS

[scrolling](https://material.angular.io/cdk/scrolling/overview)に従います。

バーチャルスクロールでは前のDOMを使い回します。単純なリスト構造なら問題ないのですが、状態に応じて表示が変化するため、使いまわさないようにする必要があります。
`templateCacheSize: 0` で行えます。

レベルを各ノードに持たせているので、 `[style.padding-left]="node.level * 32 + 'px'"` で $レベル \times 32px$　分、左にパディングを持たせます。

`@if (node.hasChild)` で子要素を持っていたら右側に開閉アイコンを表示します。
これは [MatTree](https://material.angular.io/components/tree/examples) をパクります。

アイコンを横並びにするために `flex-direction: row;` と `align-items: center;` で整えます。

## TypeScript

要素は3つ必要です。

- `originalData`
    大元
- `expandData`
    大元を拡張した構造体で、状態管理の大元となる配列です。
    親ノードIDや開閉状態などを拡張します。親ノードが閉じている場合に子要素を表示しないようにする必要があったり、今回はないですが選択状態も表示したい場合に必要になります。
    状態が変化するたびに、この配列から実際に表示される内容を生成しなおします。
- `flattenData`
    バーチャルスクロールに表示させるデータです。
    level=0をルートとします。

他必要そうな部分を説明します。

### 開閉

```ts
/** 開閉 */
changeExpand(target: FlatObjType): void {...}

/**
 * 開閉変更
 * @param target 選択されたノード
 * @param nodes 変更対象配列
 * @param parentSet 閉じられている親組織一覧
 */
private ExpandChange(
  target: FlatObjType,
  nodes: NestedObjExpandType[],
  parentSet: Set<string>
): void {...}
```

`expandData` にある対象ノードと影響を受けるノードを変化させていきます。

対象ノードは下記のようにするだけです。

```ts
if (item.id === target.id) {
  item.expanded = !item.expanded;
}
```

閉じられる場合、配下子ノードも全て閉じて消してやる必要があります。

```ts
// 閉じる場合
if (target.expanded) {
  // 親組織を持っていれば対象組織の子組織も閉じる
  if (item.parentId && parentSet.has(item.parentId)) {
    item.expanded = false;
    parentSet.add(item.id);
  }
}
```

あとは再帰的に行います。 `parentSet` は呼び出し元で作成して渡してやることでメモ化できます。

```ts
this.ExpandChange(target, item.children, parentSet);
```

### `flattenData` の再構築

```ts
/** バーチャルスクロールリストを再構成 */
private updateVirtualScrollList(): FlatObjType[] {...}
```

現在の状態を保持する `expandData` から、flatMapを用いてバーチャルスクロールに表示させるデータを構築します。

```ts
this.expandData.flatMap((item) => this.makeFlat(item))
```

親ノードが閉じている場合、表示を行わないため `!parentSet.has(item.parentId)` にて弾きます。 順番は上記flatMapの処理にて担保されているため、 `!parentSet.has(item.parentId)` で親が閉じられていれば含めないようにします。
自身が閉じられていれば `if (!item.expanded) parentSet.add(item.id);` で閉じられた親ノード一覧に含めます。

また、一番上のノードは消えることがないので型ガードも兼ねて `item.parentId === undefined` で弾きます。

```ts
.filter((item) => {
  if (!item.expanded) {
    parentSet.add(item.id);
  }
  return item.parentId === undefined || !parentSet.has(item.parentId);
});
```

この処理を最初と開閉時に行うことでバーチャルスクロールに与えるリストが再構築されます。

これで完成です！

# おわりに

読んで頂きありがとうございました。

再帰だらけで脳がバグりますが、なんとか頑張りましょう。
今回は開閉など頑張って再帰しましたが、Visitorパターンを使えばもっと楽になる気がします。
また、データの拡張もフラット化も、別のデータ構造を定義して再構成しましたが、クラスを作成して、ラップしてあげてもいいかもしれません。

あと語りはこんなところです。

以上、[ゆるプロ▼ Advent Calendar 2023](https://qiita.com/advent-calendar/2023/yurupro)の24日目でした！
良いお年を！