【Angular】Angular CDK のDrag and Drop使ってみる

2020年3月4日

  • Angular CLIが利用できる前提です。もし利用したことない場合は、こちらに環境のセットアップ方法が乗っているので参考にしてください。

プロジェクトの作成

新しくプロジェクトを作成します。

ng new drag-and-drop-sample  --routing --style=scss

AngularMaterialを追加して、Drag And Dropを利用できるようにします。

ng add @angular/material

実行中にいくつか質問されます。
■Choose a prebuilt theme name, or “custom" for a custom theme
⇒これは何でもOKです。とりあえず私はIndigo/Pinkを選んでおきます。

■Set up HammerJS for gesture recognition?
⇒HammerJSはデバイスやブラウザ間のジェスチャー系操作をまとめてくれる便利な奴。とりあえず「Y」を選択しおくのが無難。

■Set up browser animations for Angular Material?
⇒ Animationは今回使わないので、「n」でOKです。

単純なドラッグ&ドロップの実装

まず初めに、app.module.tsに DragDropModule を追加します。

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { DragDropModule } from '@angular/cdk/drag-drop'; // 追加

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    NoopAnimationsModule,
    DragDropModule // 追加
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

次に、 app.component.htmlにドラッグできる要素を準備します。

<div class="box" cdkDrag>
  Drag
</div>

divの要素がわかりやすいように、app.component.scssでスタイル整えます。

.box {
  width: 200px;
  height: 200px;
  border: solid 1px #ccc;
  cursor: move;
}

簡単なドラッグ&ドロップはこれで完成です。
せっかくなので、Trelloみたいなものを作ってみようかと思います。

Trelloっぽいもの作ってみる

まず必要になりそうなコンポーネントを作成しておきます。
全体を表示するボード、一つ表示するアイテムくらいあればいいですかね。あ、あとはアイテム編集用のダイアログもあるとさらによさそうですね。
カラムを表示するコンポーネントもあるとさらに分割できてよいのですが、そうするとカラム間の移動がうまくできなかったので、今回はカラム用のコンポーネントなしで作成します。

ng g c task-board
ng g c task-item
ng g c task-item-edit-dialog

あとはタスク用のクラス用意して、必要になりそうなプロパティ用意しておきましょう。

ng g class models/task
export class Task {
  id: number;
  title: string;
  description: string;
}

export class TaskColumn {
  taskList: Task[];
}

export class TaskBoard {
  state: string;
  columnList: TaskColumn[];
}

ここまでできたら、ルーティングの設定をしておいてあげます。
app-routing.module.tsを以下のように修正します。

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { TaskBoardComponent } from './task-board/task-board.component';

const routes: Routes = [
  {
    path: '',
    component: TaskBoardComponent
  }
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule { }

あと、簡単なドラッグ&ドロップを実装したままだと思うので、そちらも削除しておきましょう。
app.component.htmlを以下のように修正します。(div要素を削除します)

<router-outlet></router-outlet>

それに伴ってスタイルの定義も不要になるので、app.component.scssも空の状態にしちゃってOKです。

ここまでできたら一度立ち上げてみます。

ng serve -o

「task-board works!」という文言が表示されていればOKです!
後はどんどん実装していきます。

まずはtask-item.component.tsで、一つのタスクを受け取れるようにします。

import { Component, OnInit, Input } from '@angular/core';
import { Task } from '../models/task';

@Component({
  selector: 'app-task-item',
  templateUrl: './task-item.component.html',
  styleUrls: ['./task-item.component.scss']
})
export class TaskItemComponent implements OnInit {
  @Input() item: Task = new Task(); // 追加
  constructor() { }

  ngOnInit() {
  }

}

次にtask-item.component.htmlでそれを表示します。

<div class="title">{{item.title}}</div>

task-itemコンポーネントに関しては、上記修正だけでとりあえずOKです。
次にtask-boardコンポーネント修正していきます。

まずはtask-board.component.tsを以下のように修正します。

import { Component, OnInit } from '@angular/core';
import { TaskBoard } from '../models/task';
import { moveItemInArray, CdkDragDrop, transferArrayItem } from '@angular/cdk/drag-drop';

@Component({
  selector: 'app-task-board',
  templateUrl: './task-board.component.html',
  styleUrls: ['./task-board.component.scss']
})
export class TaskBoardComponent implements OnInit {
  connectedTo: string[] = [];
  board: TaskBoard = {
    columnList: [
      {
        state: 'やること',
        id: '1',
        taskList: [
          {
            id: 1,
            title: 'あああ',
            description: '',
          },
          {
            id: 2,
            title: 'test',
            description: '',
          },
        ],
      },
      {
        state: '実行中',
        id: '2',
        taskList: [
          {
            id: 1,
            title: 'あああ',
            description: '',
          },
          {
            id: 2,
            title: 'test',
            description: '',
          },
        ],
      }
    ]
  };
  constructor() { }

  ngOnInit() {
    for (const column of this.board.columnList) {
      this.connectedTo.push(column.id);
    }
  }

  drop(event: CdkDragDrop<string[]>) {
    if (event.previousContainer === event.container) {
      moveItemInArray(
        event.container.data,
        event.previousIndex,
        event.currentIndex);
    } else {
      transferArrayItem(
        event.previousContainer.data,
        event.container.data,
        event.previousIndex,
        event.currentIndex);
    }
  }
}

次にtask-board.component.htmlを以下のように修正します。

<div class="container" *ngFor="let column of board.columnList">
  <p>{{ column.state }}</p>
  <div
    cdkDropList
    id="{{column.id}}"
    class="list"
    [cdkDropListData]="column.taskList"
    [cdkDropListConnectedTo]="connectedTo"
    (cdkDropListDropped)="drop($event)">
    <div class="box" *ngFor="let task of column.taskList" cdkDrag>
      <app-task-item [item]="task"></app-task-item>
    </div>
  </div>
</div>

最後にtask-board.component.scssでスタイルを調整します。

.container {
  width: 400px;
  max-width: 100%;
  margin: 0 25px 25px 0;
  display: inline-block;
  vertical-align: top;
}

.list {
  border: solid 1px #ccc;
  min-height: 60px;
  background: white;
  border-radius: 4px;
  overflow: hidden;
  display: block;
}

.box {
  padding: 20px 10px;
  border-bottom: solid 1px #ccc;
  color: rgba(0, 0, 0, 0.87);
  display: flex;
  flex-direction: row;
  align-items: center;
  justify-content: space-between;
  box-sizing: border-box;
  cursor: move;
  background: white;
  font-size: 14px;
}

DragDropModuleならではの定義部分と、ポイントっぽいところ説明しておきます。

■task-board.component.html
・cdkDropList:ドラッグ可能なアイテムのセットをラップするコンテナに添付するディレクティブ。以下の3つのプロパティを利用している。
1.[cdkDropListData]:cdkDropList のプロパティ。コンテナに添付する任意のデータ
2.[cdkDropListConnectedTo]:アイテムをドロップ可能なコンテナの参照、またはID
3.(cdkDropListDropped):コンテナ内にアイテムをドロップすると発生するイベント

・cdkDrag:CdkDropListコンテナ内で移動できる要素に添付するディレクティブ

■task-board.component.ts
・connectedTo: [cdkDropListConnectedTo] で設定する一意となるIDを設定
・moveItemInArray:コンテナ内のあるインデックスを別のインデックスに移動
・transferArrayItem:あるコンテナから別のコンテナにアイテムを移動

とりあえずこれで横縦にタスクを移動させることができるようになりました。

タスクの追加とか、カラムの追加とかもやりたいのですが、基本的にはTaskBoardComponent内で定義している、「board」変数にpushしたりすれば動的に変わるはずなので(未確認)、そんなに難しくないと思います。

なので最後にTrelloっぽくアイテムをダイアログで表示して編集するところまでやって終わりにしたいと思います。

タスクをダイアログで編集

AngularMaterialに便利な MatDialogModule があるので、それを活用します。
あとついでにボタンも使いたいので、 MatButtonModule も追加します。
編集用に必要なので、 FormsModule も追加します。
app.module.tsに以下のように追加していきます。

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms'; // 追加
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { MatButtonModule } from '@angular/material/button'; // 追加
import { MatDialogModule } from '@angular/material/dialog'; // 追加
import { DragDropModule } from '@angular/cdk/drag-drop';

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { TaskBoardComponent } from './task-board/task-board.component';
import { TaskItemComponent } from './task-item/task-item.component';
import { TaskItemEditDialogComponent } from './task-item-edit-dialog/task-item-edit-dialog.component';

@NgModule({
  declarations: [
    AppComponent,
    TaskBoardComponent,
    TaskItemComponent,
    TaskItemEditDialogComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    FormsModule, // 追加
    NoopAnimationsModule,
    DragDropModule,
    MatButtonModule, // 追加
    MatDialogModule // 追加
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

次にダイアログ表示用のコンポーネント編集していきます。
task-item-edit-dialog.component.tsにMatDialogRef 追加して、クローズ処理を書いていきます。
またアイテムを一つ受け取って編集したいので、それも受け取れるようにしておきます。

import { Component, OnInit, Inject } from '@angular/core';
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; // 追加
import { Task } from '../models/task'; // 追加

@Component({
  selector: 'app-task-item-edit-dialog',
  templateUrl: './task-item-edit-dialog.component.html',
  styleUrls: ['./task-item-edit-dialog.component.scss']
})
export class TaskItemEditDialogComponent implements OnInit {

  initItem: Task = new Task();
  constructor(
    private dialogRef: MatDialogRef<TaskItemEditDialogComponent>, // 追加
    @Inject(MAT_DIALOG_DATA) public data: Task // 追加
  ) { }

  ngOnInit() {
    this.initItem = JSON.parse(JSON.stringify(this.data)); // 追加
  }

  // 追加
  apply() {
    this.dialogRef.close(this.data);
  }

  // 追加
  cancel(): void {
    this.dialogRef.close(this.initItem);
  }

}

ngOnInit のタイミングで、キャンセルが押されたとき用にもとの値を保持したアイテムとしてディープコピーしておきます。

ダイアログ画面ではタイトルと説明を編集できるようにします。
また適用 or キャンセルボタンを用意していきます。
task-item-edit-dialog.component.html では以下のようになります。

<div>
  <p>
    タイトル:<input [(ngModel)]="data.title">
  </p>
  <p>
    詳細:<input [(ngModel)]="data.description">
  </p>
</div>

<div>
  <button mat-stroked-button (click)="cancel()">キャンセル</button>
  <button mat-flat-button color="primary" (click)="apply()">適用</button>
</div>

ここまでくれば後は呼び出し側からこのダイアログコンポーネント呼び出して、値を取得すればOKです。

まずは task-item.component.ts にメソッド追加していきます。

import { Component, OnInit, Input, OnDestroy } from '@angular/core';
import { Task } from '../models/task';
import { MatDialog } from '@angular/material/dialog';
import { TaskItemEditDialogComponent } from '../task-item-edit-dialog/task-item-edit-dialog.component'; // 追加
import { Subscription } from 'rxjs'; // 追加

@Component({
  selector: 'app-task-item',
  templateUrl: './task-item.component.html',
  styleUrls: ['./task-item.component.scss']
})
export class TaskItemComponent implements OnInit, OnDestroy {
  @Input() item: Task = new Task();
  subscription: Subscription = new Subscription(); // 追加
  constructor(private dialog: MatDialog) { }

  ngOnInit() {
  }

  // 追加
  ngOnDestroy(): void {
    this.subscription.unsubscribe();
  }

  // 追加
  edit() {
    const dialogRef = this.dialog.open(TaskItemEditDialogComponent, {
      data: this.item,
      disableClose: true
    });
    this.subscription.add(dialogRef.afterClosed().subscribe(result => {
      if (result) {
        this.item = result;
      }
    }));
  }
}

edit メソッドでダイアログの呼び出しと、クローズ後の処理を行っています。
disableCloseは、ダイアログをescやダイアログ外をクリックすることで非表示にする機能をOFFにしています。
ダイアログがクローズするときにはafterClosed()が発火するので、そこで結果受け取って反映しています。

最後にこの edit メソッドを呼び出すボタンをhtmlに書いていきます。
task-item.component.html を以下のように修正すればOKです。

<button mat-button (click)="edit()">
  <div class="title">{{item.title}}</div>
</button>

ここまでできたら、ng serve -o でブラウザ立ち上げて、アイテムクリックすれば編集用のダイアログ表示できるかと思います。

※ちなみにIvy オプションを がOFFになっているとうまく立ち上がりません。
https://angular.jp/guide/ivy
IvyをONにするか、もしくは app.module.ts の @NgModule で、 entryComponents にダイアログで表示する予定のコンポーネントを追加する必要があります。

@NgModule({
  declarations: [
    AppComponent,
    TaskBoardComponent,
    TaskItemComponent,
    TaskItemEditDialogComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    FormsModule,
    NoopAnimationsModule,
    DragDropModule,
    MatButtonModule,
    MatDialogModule
  ],
  providers: [],
  bootstrap: [AppComponent],
  entryComponents: [TaskItemEditDialogComponent] // IvyOFFだとこんな感じで追加必要
})

これでTrelloっぽいうごく画面がちょっとできたのではないでしょうか!
ただデザインは何も入れていないので、ものすごく地味な画面ですが。。。

上記のソースはGitHubにも上げているので、良かったら参考にしてみてください!
https://github.com/kanazawanao/drag-and-drop-sample

一応 StackBlitz でも確認ができますが、上記に書いた entryComponents にコンポーネントを追加する必要があるみたいです、、、
ちょっと手動での修正が必要ですが、簡単に見たいよって方はこちら参考にしてみてください。
https://stackblitz.com/github/kanazawanao/drag-and-drop-sample

続きも書きました!
動的に動くようにしてみたので、よかったらみてください。