【Flutter】iOSの写真選択機能のような一覧をなぞって選択できるUIを実装してみた

# はじめに
<!-- 発端や概要を記載 -->
こういったなぞってアイテムを選択できるようなUIを実装してみたいと思い、実装してみましたので記事にしました。

![select.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2475427/38fda422-a60f-5a3f-4bd1-85873d50d7b8.gif)

作ったもの

![sample.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2475427/f8b3879a-ac55-3c74-d432-34085cf37261.gif)

<a id="#ソースコード"></a>
# ソースコード
https://github.com/AppDeveloperMLLB/sample_swipe_selector
ソースコードです。
汎用的かつわかりやすい形にリファクタリングしたいですが、できてないです。

<a id="#実装で詰まったところ"></a>
# 実装で詰まったところ

Flutterをある程度実装したことがある方なら使ったことがあると思いますが、タッチ関係のイベントを処理する際は、GestureDetector`を使用します。

今回も`GestureDetector`のonPanStart,onPanUpdateあたりを使用する使用する予定でした。

しかし、これらはタップが開始された`GestureDetector`でしかイベントを検知できず、他のWidget上でタッチが開始され、画面に触れたまま指をスライドさせて、別のWidget上に指をスライドさせても検知ができないという問題が発覚しました。

調べたところ、「`RenderBox`クラスのメソッド[hitTest](https://api.flutter.dev/flutter/rendering/RenderBox/hitTest.html)でタップされたオブジェクトを取得する」という方法なら可能なようです。
詳細は下記を参照。

https://programmer-record.com/flutter-gesture-longtap-detector/

`hitTest`というはメソッドは、`RenderObject`を取得できます。
したがって、「タップを検知したいWidget」と「タップを検知できるRenderObject」を紐づければ、`hitTest`を使ってタップを検知できます。

紐づけるための方法ですが、`SingleChildRenderObjectWidget`を使います。
`SingleChildRenderObjectWidget`は一つだけの子ウィジェットを持つことができる`RenderObject`の派生クラスです。
まとめると、`SingleChildRenderObjectWidget`の子に、タップを検知したいWidgetを持たせ、`hitTest`というはメソッドで検知するという手順になります。

なお、`RenderObject`について詳しく知りたい人は下記の記事がオススメです。

https://qiita.com/kurun_pan/items/d74e622b8b1d2843e578

<a id="#ソースコードのポイント"></a>
# ソースコードのポイント

まずみて欲しいのは、item.dartの`TouchDetector`です。
このクラスはSingleChildRenderObjectWidgetを拡張したサブクラスです。
各種必要なコールバックと、検知させたいWidgetを引数にとります。

そして、createRenderObjectとupdateRenderObjectで、独自で作成した`TouchDetectorRenderBox`を生成、更新するようにしています。
```dart
class TouchDetector extends SingleChildRenderObjectWidget {

　// 省略

  @override
  RenderObject createRenderObject(BuildContext context) {
    final renderObject = TouchDetectorRenderBox();
    renderObject.onTouch = onTouch;
    renderObject.getIndex = getIndex;
    renderObject.isSelected = isSelected;
    return renderObject;
  }

  @override
  void updateRenderObject(
    BuildContext context,
    TouchDetectorRenderBox renderObject,
  ) {
    super.updateRenderObject(context, renderObject);
    renderObject.onTouch = onTouch;
    renderObject.getIndex = getIndex;
    renderObject.isSelected = isSelected;
  }
}
```

hitTestを使い、ヒットしていたら各種コールバックを実行したいので、独自でクラスを定義し、メンバ変数として保持しています。
```dart
class TouchDetectorRenderBox extends RenderProxyBox {
  // RenderBoxのhitTestメソッドを使い、
  // タッチされていればタッチした時の処理を行うために定義しておく
  VoidCallback? onTouch;
  int Function()? getIndex;
  bool Function()? isSelected;
}
```

そして、これを呼び出す側はどこかというと、ItemGridViewになります。
（記事を書いているときにコードを見返すと、もっといい定義場所や書き方がありそうです。またリファクタリングしたいと思います。。。）

_judgeHitに注目してもらいたいのですが、こちらは以下の引数を受け取ります
- WidgetRef
- BuildContext
- GestureDetectorの各種コールバック（onPanStartなど）で取得できるglobalPositionを受け取ります。

まずWidgetRefを使い、写真を選択中のモードかどうかを取得し、選択中ではない場合、何もしません。
選択中かどうかの切り替えは、MyHomePageのAppBarのトグルボタンで変更します。
下記写真の右上のボタンです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2475427/2ee6daee-b635-60f6-3a6e-9deaae0eab72.png)

選択中の場合の説明は、コード上のコメントで説明した方がわかりやすいと思ったのでコメントに書きました！
```dart
void _judgeHit(
    WidgetRef ref,
    BuildContext context,
    Offset globalPosition,
  ) {
    if (!ref.read(isSelectedProvider)) {
      return;
    }
    
    // BuildContextのfindRenderObjectでRenderBoxを取得
    // findRenderObjectはBuildContextに紐づけられたRenderBoxを取得するので、
    // この場合、GestureDetectorのRenderBoxを取得する
    final RenderBox? box = context.findRenderObject() as RenderBox?;
    final result = BoxHitTestResult();

    // GestureDetectorのRenderBoxに対するローカル座標に変換
    var local = box?.globalToLocal(globalPosition);

    if (box == null || local == null) {
      return;
    }

    // GestureDetectorのRenderBoxでhitTestを行う
    if (box.hitTest(result, position: local)) {
      for (final hit in result.path) {
        final target = hit.target;

        // 独自で定義したTouchDetectorRenderBoxかどうか判定
        if (target is TouchDetectorRenderBox) {
          // インデックスを取得するために定義したコールバックを実行
          final index = target.getIndex?.call() ?? 0;
          // データを取得
          final touchedData = ref.read(dataListProvider)[index];
          // 選択状態を反転
          final newValue = !touchedData.isSelected;
          if (ref.read(isChangingToSelected) == null) {
            ref.read(isChangingToSelected.notifier).state = newValue;
          }

          // タップ開始位置がnullの場合、開始位置を指定
          tapStartIndex ??= index;


          // なぞり方によってindexが飛ぶ場合がある。
          // indexが0,1,2...と順番になぞるわけではなく、0をタップした後に5に指をスライドさせた場合、
          // 0と5の間のデータも選択状態としたいので、選択状態にできるように制御している
          if (tapStartIndex! <= index) {
            for (int i = tapStartIndex!; i <= index; i++) {
              final data = ref.read(dataListProvider)[i];
              if (data.isSelected != ref.read(isChangingToSelected)) {
                ref.read(dataListProvider.notifier).toggleIsSelected(i);
              }
            }
          } else {
            for (int i = tapStartIndex!; i >= index; i--) {
              final data = ref.read(dataListProvider)[i];
              if (data.isSelected != ref.read(isChangingToSelected)) {
                ref.read(dataListProvider.notifier).toggleIsSelected(i);
              }
            }
          }
        }
      }
    }
  }
```

# おわりに
かなり複雑なコードになってしまいました。。。
不明点や、こちらの認識が誤っている部分あれば、コメントいただければ幸いです。