【Flutter】ドラッグ&ドロップの実装

# はじめに
以前AndroidでViewをドラッグ&ドロップする方法について紹介しました。
（AndroidでViewをドラッグ&ドロップする方法は[こちら](https://qiita.com/umechanhika/items/56add1e55f0ed921a596)をご覧ください。）
今回はFlutterでどのようにWidgetをドラッグ&ドロップするのか調査したのでまとめます。
ちなみに今回は[こちら](https://medium.com/flutter-community/a-deep-dive-into-draggable-and-dragtarget-in-flutter-487919f6f1e4)の英文記事を参考にさせていただきました。
ソースコードは[Github](https://github.com/umechanhika/playground/tree/master/flutter/drag_and_drop)で公開しています。


# Draggableの実装
Flutterでドラッグ可能なWidgetを作成するには、[Draggable](https://docs.flutter.io/flutter/widgets/Draggable-class.html)を使います。
Draggableの`child`にドラッグしたいWidgetを指定することで、そのWidgetをドラッグ可能にします。
また、Draggableは`feedback`というプロパティが必須となっています。
これはドラッグ中に表示されるWidgetのことです。
必須ではありませんが、`childWhenDragging`を指定するとドラッグ中にchildの表示を変更できます。

実装は下記の通りです。

```dart:main.dart
class _HomeState extends State<Home> {

  @override
  Widget build(BuildContext context) {
    return new Scaffold(
        appBar: new AppBar(
          title: new Text("Drag & Drop"),
        ),
        body: Center(child: buildDraggable("Draggable", Icons.filter_1)));
  }

  Draggable buildDraggable(String name, IconData childIcon) =>
      new Draggable(
        child: Icon(
          childIcon,
          size: 90,
        ),
        feedback: Icon(
          Icons.android,
          size: 90,
        ),
        childWhenDragging: Icon(
          Icons.flag,
          size: 90,
        ),
      );
}
```

![output.gif](https://qiita-image-store.s3.amazonaws.com/0/136368/aa43c29d-ba6e-e491-cf7b-023c4cbc59e5.gif)
1のアイコンがchildでドロイド君のアイコンがfeedback、そしてフラッグのアイコンがchildWhenDraggingです。

## Draggableのイベントハンドリング
Draggableでハンドリングできるイベントは全部で4つあります。

- onDragStarted
    - ドラッグが開始された際に呼ばれます。
    - 引数・戻り値なしのFuncitonです。
- onDraggableCanceled
    - 後述する`DragTarget`以外のWidgetにドロップされた際に呼ばれます。
    - またはDragTargetに受け入れられていない状態でそのDragTargetにドロップした際に呼ばれます。
    - 引数に`Velocity`, `Offset`を持つ戻り値なしのFunctionです。
        - Velocityはユーザー操作の速度を表すクラスです。pixel/secondで表されます。
        - Offsetは平面上のx座標・y座標を表すクラスです。
- onDragCompleted
    - 後述する`DragTarget`に受け入れられた状態でそのDragTargetにドロップされた際に呼ばれます。
    - 引数・戻り値なしのFuncitonです。
- onDragEnd
    - ドロップされた際に呼ばれます。
    - `DraggableDetails`を引数に持つ戻り値なしのFunctionです。
        - DraggableDetailsはDragTargetの受け入れ状態とVelocity、そしてOffsetを持つクラスです。

実装は下記の通りです。

```dart:main.dart
  Draggable buildDraggable(String name, IconData childIcon) =>
      new Draggable(
        onDragStarted: () {
          print("Draggable.onDragStarted:");
        },
        onDraggableCanceled: (velocity, offset) {
          print("Draggable.onDraggableCanceled: velocity: $velocity, offset: $offset");
        },
        onDragCompleted: () {
          print("Draggable.onDragCompleted:");
        },
        onDragEnd: (details) {
          print("Draggable.onDragEnd: details: $details");
        },
        child: Icon(
          childIcon,
          size: 90,
        ),
        feedback: Icon(
          Icons.android,
          size: 90,
        ),
        childWhenDragging: Icon(
          Icons.flag,
          size: 90,
        ),
      );
```

上記GIFと同じように操作した際の呼び出し順を確認してみます。

```markdown:各イベントの発生順
Draggable.onDragStarted:
Draggable.onDragEnd: details: Instance of 'DraggableDetails'
Draggable.onDraggableCanceled: velocity: Velocity(0.0, 0.0), offset: Offset(72.0, 232.7)
```

onDragCompletedは`DragTarget`が存在して尚且つ受け入れ可能な状態になっている場合にしか呼び出されないため、今回は呼び出されていません。

## データを持たせる
Draggableにはデータを持たせることができます。
このデータは後述する`DragTarget`で受け取ることができます。

実装は下記の通りです。

```dart:main.dart
  Draggable buildDraggable(String name, IconData childIcon) => new Draggable(
        data: "I'm $name!", // add this line
        onDragStarted: () {
          print("DEBUG: Draggable.onDragStarted:");
        },
        onDraggableCanceled: (velocity, offset) {
          print(
              "DEBUG: Draggable.onDraggableCanceled: velocity: $velocity, offset: $offset");
        },
        onDragCompleted: () {
          print("DEBUG: Draggable.onDragCompleted:");
        },
        onDragEnd: (details) {
          print("DEBUG: Draggable.onDragEnd: details: $details");
        },
        child: Icon(
          childIcon,
          size: 90,
        ),
        feedback: Icon(
          Icons.android,
          size: 90,
        ),
        childWhenDragging: Icon(
          Icons.flag,
          size: 90,
        ),
      );
```

# DragTargetの実装
Draggableではchildを用いてドラッグするWidgetを指定していましたが、DragTargetでは`builder`を用います。
builderは`BuildContext context`, `List<T> candidateData`, `List<dynamic> rejectedData`を引数に持つWidgetを返すFunctionです。（`T`にはDraggableから受け取るデータと同じ型を指定します。）
引数については後ほど説明します。

実装は下記の通りです。

```dart:main.dart
  DragTarget buildDragTarget() => new DragTarget(
        builder: (context, candidateData, rejectedData) {
          return new Container(
            width: 90,
            height: 90,
            color: Colors.red,
          );
        },
      );
```

全体の実装は下記のようになりました。

```dart:main.dart
class _HomeState extends State<Home> {
  @override
  Widget build(BuildContext context) {
    return new Scaffold(
        appBar: new AppBar(
          title: new Text("Drag & Drop"),
        ),
        body: Center(
            child: Column(
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            Padding(
              padding: const EdgeInsets.all(24.0),
              child: buildDragTarget(),
            ),
            Padding(
              padding: const EdgeInsets.all(24.0),
              child: buildDraggable("Draggable", Icons.filter_1),
            ),
          ],
        )));
  }

  Draggable buildDraggable(String name, IconData childIcon) => new Draggable(
        data: "I'm $name!",
        onDragStarted: () {
          print("Draggable.onDragStarted:");
        },
        onDraggableCanceled: (velocity, offset) {
          print("Draggable.onDraggableCanceled: velocity: $velocity, offset: $offset");
        },
        onDragCompleted: () {
          print("Draggable.onDragCompleted:");
        },
        onDragEnd: (details) {
          print("Draggable.onDragEnd: details: $details");
        },
        child: Icon(
          childIcon,
          size: 90,
        ),
        feedback: Icon(
          Icons.android,
          size: 90,
        ),
        childWhenDragging: Icon(
          Icons.flag,
          size: 90,
        ),
      );

  DragTarget buildDragTarget() => new DragTarget(
        builder: (context, candidateData, rejectedData) {
          return new Container(
            width: 90,
            height: 90,
            color: Colors.red,
          );
        },
      );
}
```

![output.gif](https://qiita-image-store.s3.amazonaws.com/0/136368/16dd0961-1c5d-f98a-5740-54078fdd225a.gif)
赤い四角がDragTargetです。

## DragTargetのイベントハンドリング
DragTargetでハンドリングできるイベントは全部で4つあります。
（builderはDragTargetのWidget生成関数ではありますが、呼ばれるタイミングが複数あるためここに記載します。）

- builder
    - Widget生成時、Draggableが重なった時、Draggableが重なった状態から抜けた時、DraggableがDragTargetにドロップされた時に呼ばれます。
    - candidateData, rejectedDataのどちらもDraggableがDragTargetに重なったタイミングで値が入ります。
    - 下記のonWillAcceptがtrueを返す場合はcandidateDataに、falseの場合はrejectedDataに値が入ります。（公式ドキュメントには上記の通り記載がありましたが、動作確認したところ、rejectedDataに値が入ることだけは確認できませんでした。）
- onWillAccept
    - Draggableが重なった時に呼ばれます。
    - 引数にDraggableから受け取ったデータを持つ戻り値boolのFunctionです。
    - 戻り値のboolでDraggableを受け入れるかどうかを表現します。
- onAccept
    - onWillAcceptがtrueを返していて尚且つDragTargetにドロップされた時に呼ばれます。
    - 引数にDraggableから受け取ったデータを持つ戻り値なしのFunctionです。
- onLeave
    - DraggableとDragTargetが重なった状態から抜けた場合に呼ばれます。
    - onWillAcceptがfalseを返した場合は、それに加えてDraggableがDragTargetにドロップされた場合にも呼ばれます。
    - 引数にDraggableから受け取ったデータを持つ戻り値なしのFunctionです。

実装は下記の通りです。

```dart:main.dart
  DragTarget buildDragTarget() => new DragTarget(
        builder: (context, candidateData, rejectedData) {
          print("DragTarget.builder: candidateData: $candidateData, rejectedData: $rejectedData");
          return new Container(
            width: 90,
            height: 90,
            color: Colors.red,
          );
        },
        onWillAccept: (data) {
          print("DragTarget.onWillAccept: data: $data");
          return true;
        },
        onAccept: (data) {
          print("DragTarget.onAccept: data: $data");
        },
        onLeave: (data) {
          print("DragTarget.onLeave: data: $data");
        },
      );
```

上記GIFのように操作した場合のDraggableとDragTargetの二つのイベントの流れは下記のようになります。

```markdown:各イベントの発生順
## Widget生成→ドラッグ開始→DragTarget上でドロップした場合
DragTarget.builder: candidateData: [], rejectedData: []
Draggable.onDragStarted:
DragTarget.onWillAccept: data: I'm Draggable!
DragTarget.builder: candidateData: [I'm Draggable!], rejectedData: []
（onWillAcceptの戻り値がfalseの場合は、candidateData: [], rejectedData: [I'm Draggable!]）
DragTarget.onAccept: data: I'm Draggable!
Draggable.onDragEnd: details: Instance of 'DraggableDetails'
Draggable.onDragCompleted:
DragTarget.builder: candidateData: [], rejectedData: []

## Widget生成→ドラッグ開始→DragTargetに重なった状態から抜け→ドロップした場合
DragTarget.builder: candidateData: [], rejectedData: []
Draggable.onDragStarted:
DragTarget.onWillAccept: data: I'm Draggable!
DragTarget.builder: candidateData: [I'm Draggable!], rejectedData: []
（onWillAcceptの戻り値がfalseの場合は、candidateData: [], rejectedData: [I'm Draggable!]）
DragTarget.onLeave: data: I'm Draggable!
DragTarget.builder: candidateData: [], rejectedData: []
Draggable.onDragEnd: details: Instance of 'DraggableDetails'
Draggable.onDraggableCanceled: velocity: Velocity(0.0, 0.0), offset: Offset(162.2, 159.2)
```

# Draggable, DragTargetを実装する際の注意点
DraggableとDragTargetのジェネリクスの型（DragされるデータとDropされるデータの型）が同じでないとDragTargetの各種メソッド（builder, onWillAcceptなど）が呼ばれないので、ジェネリクスで型の指定をする際には注意してください。