Angular Material でグリグリ動かせるタブレイアウトを作る

はじめに

はじめまして、おくなもです。
ビットバンクではNestやAngularを使って社内の管理画面を開発しています。

業務でよくAngularのコンポーネント集であるAngular Materialを使用しています。
Angular MaterialはAngularでマテリアルデザインを実装できるコンポーネント集です。
Angular版のBootStrapのようなテンプレート集と考えればイメージしやすいかと思います。
Angular Material

Angular MaterialのTabsコンポーネントを利用してタブを作っていたところ、ドラッグ&ドロップ可能なタブはサポートしていないことに気が付きました。
タブを見たら動かしたくなるので、実務では特に必要ありませんが作ってみることにしました。

調べたところ、Angular Materialにはdrag-drop CDKがあるのでこちらを利用して実装することにします。
Angular Material/CDK/drag-drop

本記事で使用しているバージョンは以下のとおりです。

  • "@angular/cdk": "7.3.3"
  • "@angular/material": "7.3.3"

Tabsを活用した方法

TabsをCDK drag-dropを使ってドラッグ&ドロップ可能にする方法については、以下のissueで議論がされています。

angular/material2/issues/13572

手始めに、ここに投稿された内容を参考に実装してみます。

export class AppComponent {
  tabs = ['data-0', 'data-1', 'data-2'];
  selected = new FormControl(0);

  getAllListConnections(index: number) {
    const connections: string[] = [];
    for (let i = 0; i < this.tabs.length; i++) {
      if (i !== index) {
        connections.push('tab-' + i);
      }
    }

    return connections;
  }
  drop(event: CdkDragDrop<string[]>) {
    const previousIndex = parseInt(event.previousContainer.id.replace('tab-', ''), 10);
    const currentIndex = parseInt(event.container.id.replace('tab-', ''), 10);
    if (
      previousIndex !== NaN &&
      currentIndex !== NaN &&
      previousIndex !== undefined &&
      currentIndex !== undefined &&
      previousIndex !== currentIndex
    ) {
      // ドラッグしたタブを選択する
      this.selected.setValue(currentIndex);
      moveItemInArray(this.tabs, previousIndex, currentIndex);
    }
  }

  exit(event: CdkDragSortEvent<string[]>) {
    const exitedIndex = parseInt(event.item.dropContainer.id.replace('tab-', ''), 10);
    // セレクトされていないタブをドラッグした際にそのタブを選択させる。
    this.selected.setValue(exitedIndex);
  }
}
<mat-tab-group [selectedIndex]="selected.value" (selectedIndexChange)="selected.setValue($event)">
  <mat-tab *ngFor="let tab of tabs; let i = index"
    ><ng-template mat-tab-label>
      <div
        [id]="'tab-' + i"
        cdkDropList
        cdkDropListOrientation="horizontal"
        cdkDropListLockAxis="x"
        (cdkDropListDropped)="drop($event)"
        (cdkDropListExited)="exit($event)"
        [cdkDropListConnectedTo]="getAllListConnections(index)"
      >
        <div class="drag-box" cdkDrag>{{ tab }}</div>
      </div></ng-template
    >{{ tab }}
  </mat-tab>
</mat-tab-group>

実装例を参考に、mat-tab-label内に入れ子のdiv要素を作成して、cdkDropListセレクタとcdkDragセレクタを用いてドラッグ可能なコンテナを作成します。
それぞれtab-の接頭辞を付けたidをつけ、cdkDropListConnectedToメソッドにidを渡すことでドロップリストを作成します。

独自に修正した部分として、新たにFormControlを導入することでタブをドラッグ&ドロップした後にドロップされたタブがセレクトされるようにdropメソッド内に実装しています。
セレクトされていないタブをドラッグしたときFormControlの挙動が不規則になるため、exitメソッドでドラッグ元のタブを選択させています。

実際の動作は以下のようになります。

movetab_001

機能的には正しく動きますが、ドラッグ中のソーティングなどは機能しておらず、期待通りの挙動にはなりませんでした。
できれば、Tabsのラベル要素そのものをドラッグ可能な要素に変換したいところです。

Tabsを活用した方法2

drag-drop CDKにはcdkDragRootElementというAPIが用意されています。
これを使用して、タブのラベルを構成している要素をドラッグ可能な要素にできます。
以下が実装です。一部の冗長なコードは省略しています。

  // 配列の末尾にダミーのタブを挿入しておく
  tabs = ['data-0', 'data-1', 'data-2', ''];

    drop(event: CdkDragDrop<string[]>) {
    const previousIndex = parseInt(event.previousContainer.id.replace('tab-', ''), 10);
    const currentIndex = parseInt(event.container.id.replace('tab-', ''), 10);
    if (
      previousIndex !== NaN &&
      currentIndex !== NaN &&
      previousIndex !== undefined &&
      currentIndex !== undefined &&
      previousIndex !== currentIndex &&
      !this.isLastTab(currentIndex)
    ) {
      // 移動後のタブを選択する
      if (this.selected.value !== currentIndex) {
        this.selected.setValue(currentIndex);
      }
      moveItemInArray(this.tabs, previousIndex, currentIndex);
    }
  }

  isLastTab(index: number): boolean {
    return index === this.tabs.length - 1;
  }
<mat-tab-group
  animationDuration="0ms"
  [selectedIndex]="selected.value"
  (selectedIndexChange)="selected.setValue($event)"
>
  <mat-tab *ngFor="let tab of tabs; let i = index" [disabled]="isLastTab(i)"
    ><ng-template mat-tab-label>
      <div
        [id]="'tab-' + i"
        cdkDropList
        cdkDropListOrientation="horizontal"
        cdkDropListLockAxis="x"
        (cdkDropListDropped)="drop($event)"
        (cdkDropListExited)="exit($event)"
        [cdkDropListConnectedTo]="getAllListConnections(i)"
      >
        <div
          class="drag-box"
          cdkDrag
          cdkDragRootElement=".mat-tab-label"
          [cdkDragDisabled]="isLastTab(i)"
          >{{ tab }}</div
        >
      </div></ng-template
    >{{ tab }}
  </mat-tab>
</mat-tab-group>

cdkDragRootElement=".mat-tab-label"でDOMツリーの上位に存在する.mat-tab-labelセレクタを探し、ドラッグ可能な要素に変換しています。
こうすることでTabsコンポーネントのラベル要素をドラッグ&ドロップできました。

movetab002

期待通りの見た目のものができたものの、機能的な問題がいくつか発生しています。
1つ目は最後のタブをドラッグできないという問題で、これは上記の実装ではダミーの空タブを追加してとりあえず対応しています。
もう1つは一度別の場所にドラッグしたタブを、ドロップしないでもとの場所に戻すことができません。これでは片手落ち感が否めません。

cdkDragRootElementを使用すると直接指定できない要素もドラッグ可能にでき便利なのですが、その反面カプセル化されたコンポーネント内の要素に紐付けられるので内部の実装によっては予期せぬバグが発生するようです。
雲行きが怪しくなってきたので、Tabsを利用するという方針を転換し、自分でタブを実装してしまうことにしました。

タブを自分で作ってしまう方法

drag-drop CDKのサンプルを参考に水平方向にドラッグ可能なボックスリストをタブに改造します。

タブの機能は[ngClass]を使用して選択中のタブラベルのスタイルを変更し、タブのコンテンツ表示を*ngIfで切り替えることで実現できそうです。
また、これまでと同様にFormControlを使用して、タブの状態を管理してしまえば実装を簡略化できそうです。
以下に実装を示します。CSSは省略しています。

export class AppComponent {
  title = 'move-tab';
  tabs = ['data-0', 'data-1', 'data-2', 'data-3'];
  selected = new FormControl(0);

  select(index: number): void {
    this.selected.setValue(index);
  }

  isSelected(index: number) {
    return index === this.selected.value;
  }

  drop(event: CdkDragDrop<string[]>) {
    moveItemInArray(this.tabs, event.previousIndex, event.currentIndex);
    this.selected.setValue(event.currentIndex);
  }
}
<div
  cdkDropList
  cdkDropListOrientation="horizontal"
  class="label-list"
  (cdkDropListDropped)="drop($event)"
>
  <div
    *ngFor="let tab of tabs; let i = index"
    (click)="select(i)"
    [ngClass]="i === selected.value ? 'label-box selected' : 'label-box'"
    cdkDrag
    >{{ tab }}</div
  >
</div>
<div *ngFor="let tab of tabs; let i = index"
  ><div class="tab-value" *ngIf="i === selected.value">Contents: {{ tab }}</div></div
>

movetab003-1

結果的に自分で実装したほうが、非常にシンプルになりドラッグ&ドロップ可能なタブとしての機能もすべて達成できました。
その代わりTabsに実装されていたアニメーションやマテリアルデザインのスタイルが必要であれば自分でCSSを実装する必要があります。

まとめ

今回のように、フレームワークでまだサポートされていない機能を実装しようとして、狙った挙動にならないというのはよくある話ではないでしょうか。
フレームワークに頼り切ってしまっていると、そこはできないとなってしまいますが、一部の機能を自分で実装できれば自由度は格段に上がります。
ありきたりですが、フレームワークを使用していても実装力は重要だよね、という話でした。

Author image
About ocknamo
expand_less