Flutter Draggableでドラッグ&ドロップして入れ替えてみる

`Draggable`を使用すれば、ドラッグができるウィジェットが作成できます。`Draggable`にはいくつかプロパティがあります。プロパティのなかには`velocity`(double値で計測される到達スピード)や`offset`(基準点からの距離: dx, dy)が出せるものもあります。
## Draggableのプロパティ
```dart
// ドラッグを開始する方法を制御する
affinity:　-> Axis?

// ドラッグ可能オブジェクトの縦横の動きを制限させる
axis:　"x","y",

// ウィジェットの下に配置
child:　-> Widget

// ドラッグ時に代わりに表示するウィジェット
childWhenDragging：　-> Widget?

// ドロップされる内容
data：　-> T?

// childDragAnchorStrategyとpointerDragAnchorStrategyの２つの関数を使用し、ドラッグしたポイントとフィードバックウィジェットの距離を計る
dragAnchorStrategy：　→ DragAnchorStrategy?

// ドラッグ中のポインターの下に表示するウィジェット
feedback：　-> Widget

// 　ターゲットを見つける為のポイントを設定(ヒットテスト)
feedbackOffset： Offset.zero,

// オブジェクトのハッシュコード
hashCode：　-> int

// ヒットテスト中の動作方法
hitTestBehavior：　→ HitTestBehavior

// 　フィードバックウィジェットをchildと同じオブジェクトにする場合はfalseに設定
ignoringFeedbackSemantics： true;

// ウィジェットが別のウィジェットに置き換わる方法を制御
key：　-> Key?

// 最大ドラッグ数の設定、0にするとドラッグできなくなる。
maxSimultaneousDrags：　1,

// オブジェクトがドロップされ、DragTargetで受け入られたときに呼び出される。
onDragCompleted： → VoidCallback?

// Draggbleがドロップされた時に呼び出される。引数に、DragTaargetの受入れ状態・ベロシティ・オフセットのクラスが入ります。
onDragEnd： → DragEndCallback?

// Draggbleがドロップされた時に呼び出される。引数に、DragTaargetの受入れ状態・ベロシティ・オフセットのクラスが入ります。
onDraggableCanceled： → DraggableCanceledCallback?

// ドラッグが開始した時に呼び出される。
onDragStarted： → VoidCallback?

// ドラッグがドラッグされたタイミングで呼び出される。
onDragUpdate： → DragUpdateCallback?

// feedbackウィジェットをルートオーバーレイに配置するかどうか。 false(近い)/true(遠い)
rootOverlay： false,

// オブジェクトのランタイム。
runtimeType： → bool
``` 
`Draggable`を長押しで起動することができるクラスです。`Draggable`のプロパティに加え下記プロパティ
が追加されます。
## LongPressDraggableのプロパティ
```dart
// ユーザーが長押してウィジェットが起動するまでの時間。
delay: Duration(milliseconds: 1000),

// ドラッグ開始時に振動させるかしないか。
hapticFeedbackOnStart:　false
```
`Draggable`ウィジェットでドロップしたデータを受け取るウィジェットで、ドラッグ可能オブジェクトがターゲット上にドラッグされるとデータが受入れられます。
## DragTargetのプロパティ
```dart
// ドロップ範囲のUI設定
builder: → DragTargetBuilder<T>

// オブジェクトのハッシュコードを設定。
hashCode: → int

// ヒットテスト中の動作方法。
hitTestBehavior: → HitTestBehavior

// ウィジェットの置き換わる方法を制御。
key: → Key?

// ドロップが成功した時に呼び出され、ドロップしてきた値を取得。
onAccept: → DragTargetAccept<T>?

// ドロップが成功した特に呼び出される。ドラッガブルのデータとドロップされた位置を取得できる。
onAcceptWithDetails: → DragTargetAcceptWithDetails<T>?

// ドロップ範囲から離れた時に呼び出される。
onLeave: → DragTargetLeave<T>?

// Draggableがターゲット内で移動する度に呼び出される。
onMove: → DragTargetMove<T>?

// ドロップできる範囲に入った時呼び出され、bool値でドロップの可否を設定できる。
onWillAccept:　(data) { return true },

// オブジェクトのランタイム。
runtimeType:  → Type
```
今回は、`Draggable`を利用して、ドラッグ&ドロップで要素を入れ替える実装に挑戦してみました。`Flutter`デフォルト状態から書いたのでフックなどは使用してません。まずは`main.dart`を修正して、

```main.dart
import 'package:flutter/material.dart';

// カテゴリのクラスを定義
class Category {
  int index;
  String name;
  bool draggable = true;
// ゲッターでindexとnameを使用
  Category(this.index, this.name);
}

void main() {
  runApp(const DraggableScreen());
}

class DraggableScreen extends StatelessWidget {
  const DraggableScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: DraggableTest(),
    );
  }
}

class DraggableTest extends StatefulWidget {
  const DraggableTest({Key? key}) : super(key: key);

  @override
  State<DraggableTest> createState() => DraggableTestState();
}

class DraggableTestState extends State<DraggableTest> {
  // List.generateとString.fromCharCodeで規則性のあるラテン文字を要素に持つリストを作成
  List<Category> list = List.generate(
      4,
      (index) =>
          Category(index, String.fromCharCode(index + 'A'.codeUnits[0])));

  int index = 0;
// indexの初期値を設定
  void updator() {
    setState(() {
      index = index + 1;
    });
  }

  DraggableTestState();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: GridView.extent(
          maxCrossAxisExtent: 120,
          children: List.generate(12, (i) {
            // 12個のwidgetを作成
            return DragTargetItem(i, list, updator);
          })),
    );
  }
}

// ignore: must_be_immutable
class DragTargetItem extends StatefulWidget {
  // indexウィジェットの内容
  final int index;
  final List<Category> list;
  final Function() updator;

  const DragTargetItem(this.index, this.list, this.updator, {Key? key})
      : super(key: key);
  @override
  // ignore: library_private_types_in_public_api
  _DragTargetState createState() => _DragTargetState();
}

class _DragTargetState extends State<DragTargetItem> {
  // ドラッグ&ドロップを判定するbool値
  bool willAccept = false;

  @override
  Widget build(BuildContext context) {
    return Stack(children: <Widget>[
      // ドラッグターゲットのウィジェット
      DragTarget(
        builder: (context, candidateData, rejectedData) {
          return Container(
            width: 100,
            height: 100,
            decoration: BoxDecoration(
                color: willAccept ? Colors.orangeAccent : Colors.white70,
                border: Border.all(color: Colors.deepOrange),
                borderRadius: BorderRadius.circular(5)),
            child: Text(widget.index.toString(),
                style: const TextStyle(color: Colors.black38, fontSize: 20.0)),
          );
        },
        onAccept: (Category? data) {
          if (category() == null) {
            setState(() {
              data?.index = widget.index;
            });
          } else {
            setState(() {
              // DragTargetItemでターゲットになったリスト内の最初の一致を返す
              final cat = widget.list
                  .firstWhere((category) => category.index == widget.index);
              // Draggableで掴んだリスト内の最初の一致を返す
              final cat2 = widget.list
                  .firstWhere((category) => category.index == data!.index);
              // swapで入れ替え
              final tmp = cat.index;
              cat.index = cat2.index;
              cat2.index = tmp;
              // indexを更新
              widget.updator();
            });
          }
          willAccept = false;
        },
        onWillAccept: (data) {
          // ignore: avoid_print
          print("ターゲット確認 $data");
          willAccept = true;
          return true;
        },
        onMove: (data) {
          setState(() {});
        },
        onLeave: (data) {
          willAccept = false;
        },
      ),
      if (category() != null && category()!.draggable)
        Draggable(
          // feedbackをchildと同じにする
          ignoringFeedbackSemantics: false,
          // ターゲットに入れたい値を設定、ここでは照合した結果を入れる
          data: category(),
          // DragTargetで受け入られた時呼び出される
          onDragCompleted: () {
            setState(() {
              for (var d in widget.list) {
                d.draggable = true;
              }
              widget.updator();
            });
            print("ドロップ完了");
          },
          onDragUpdate: (detail) {
            setState(() {});
          },
          onDragStarted: () {
            print("ドラッグ開始");
            willAccept = true;
            setState(() {
              for (var d in widget.list) {
                d.draggable = false;
              }
              widget.updator();
            });
          },
          onDragEnd: (data) {
            print("ドラッグ終了: $data");
            willAccept = false;
          },
          // ドラッグ中、ターゲットに当てられるウィジェット
          feedback: draggableWidget('B'),
          // ドラッグアイテムの初期値のウィジェット
          childWhenDragging: draggableWidget('C'),
          // ドラッグアイテムの下に位置するウィジェットでレイアウト可能
          child: draggableWidget('A'),
        ),
      // draggableできる要素に装飾
      if (category() != null && category()!.draggable)
        // ignore: avoid_unnecessary_containers
        Container(
            alignment: Alignment.bottomCenter,
            padding: const EdgeInsets.only(bottom: 5),
            child: const Text("Draggable",
                style: TextStyle(
                  color: Colors.white,
                  fontSize: 12.0,
                )))
    ]);
  }

// 引数でUIを変更できるようにしたウィジェット関数
  Widget draggableWidget(String m) {
    return Container(
        width: 100,
        height: 100,
        decoration: BoxDecoration(
            color: (m == 'C') ? Colors.orangeAccent : Colors.orange,
            border: Border.all(color: Colors.deepOrange),
            borderRadius: BorderRadius.circular(5)),
        child: Stack(children: <Widget>[
          (m == 'A')
              ? Text(widget.index.toString(),
                  style: const TextStyle(
                      color: Colors.white,
                      fontSize: 20.0,
                      decoration: TextDecoration.none))
              : Container(),
          Center(
              child: Text(category()?.name ?? widget.index.toString(),
                  style: const TextStyle(
                      color: Colors.white,
                      fontSize: 60.0,
                      decoration: TextDecoration.none)))
        ]));
  }

// １２個あるウィジェットの中にあるインスタンスのindexを照合
  Category? category() {
    final i = widget.list.indexWhere(((item) => item.index == widget.index));
    return i >= 0 ? widget.list[i] : null;
  }
}
```
次に`widget_test.dart`の`tester.pumpWidget`の引数を下記内容に変えます。
```widget_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

import 'package:flutter_application_1/main.dart';

void main() {
  testWidgets('Counter increments smoke test', (WidgetTester tester) async {
    await tester.pumpWidget(const DraggableScreen());

    expect(find.text('0'), findsOneWidget);
    expect(find.text('1'), findsNothing);

    await tester.tap(find.byIcon(Icons.add));
    await tester.pump();

    expect(find.text('0'), findsNothing);
    expect(find.text('1'), findsOneWidget);
  });
}
```
仕上がりはこのような感じです。入れ替えの過程で相当手こずりました。入れ替えのときはウィジェットを選べるようにしてあります。ときより移動前の挙動にバグが生じますが今後の課題にしたいと思います。
![画像サンプル.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1134006/33afb51b-b23b-7c10-89f1-f19d757e5579.gif)