はじめに
こういったなぞってアイテムを選択できるようなUIを実装してみたいと思い、実装してみましたので記事にしました。
作ったもの
ソースコード
https://github.com/AppDeveloperMLLB/sample_swipe_selector
ソースコードです。
汎用的かつわかりやすい形にリファクタリングしたいですが、できてないです。
実装で詰まったところ
Flutterをある程度実装したことがある方なら使ったことがあると思いますが、タッチ関係のイベントを処理する際は、GestureDetector`を使用します。
今回もGestureDetector
のonPanStart,onPanUpdateあたりを使用する使用する予定でした。
しかし、これらはタップが開始されたGestureDetector
でしかイベントを検知できず、他のWidget上でタッチが開始され、画面に触れたまま指をスライドさせて、別のWidget上に指をスライドさせても検知ができないという問題が発覚しました。
調べたところ、「RenderBox
クラスのメソッドhitTestでタップされたオブジェクトを取得する」という方法なら可能なようです。
詳細は下記を参照。
hitTest
というはメソッドは、RenderObject
を取得できます。
したがって、「タップを検知したいWidget」と「タップを検知できるRenderObject」を紐づければ、hitTest
を使ってタップを検知できます。
紐づけるための方法ですが、SingleChildRenderObjectWidget
を使います。
SingleChildRenderObjectWidget
は一つだけの子ウィジェットを持つことができるRenderObject
の派生クラスです。
まとめると、SingleChildRenderObjectWidget
の子に、タップを検知したいWidgetを持たせ、hitTest
というはメソッドで検知するという手順になります。
なお、RenderObject
について詳しく知りたい人は下記の記事がオススメです。
ソースコードのポイント
まずみて欲しいのは、item.dartのTouchDetector
です。
このクラスはSingleChildRenderObjectWidgetを拡張したサブクラスです。
各種必要なコールバックと、検知させたいWidgetを引数にとります。
そして、createRenderObjectとupdateRenderObjectで、独自で作成したTouchDetectorRenderBox
を生成、更新するようにしています。
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を使い、ヒットしていたら各種コールバックを実行したいので、独自でクラスを定義し、メンバ変数として保持しています。
class TouchDetectorRenderBox extends RenderProxyBox {
// RenderBoxのhitTestメソッドを使い、
// タッチされていればタッチした時の処理を行うために定義しておく
VoidCallback? onTouch;
int Function()? getIndex;
bool Function()? isSelected;
}
そして、これを呼び出す側はどこかというと、ItemGridViewになります。
(記事を書いているときにコードを見返すと、もっといい定義場所や書き方がありそうです。またリファクタリングしたいと思います。。。)
_judgeHitに注目してもらいたいのですが、こちらは以下の引数を受け取ります
- WidgetRef
- BuildContext
- GestureDetectorの各種コールバック(onPanStartなど)で取得できるglobalPositionを受け取ります。
まずWidgetRefを使い、写真を選択中のモードかどうかを取得し、選択中ではない場合、何もしません。
選択中かどうかの切り替えは、MyHomePageのAppBarのトグルボタンで変更します。
下記写真の右上のボタンです。
選択中の場合の説明は、コード上のコメントで説明した方がわかりやすいと思ったのでコメントに書きました!
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);
}
}
}
}
}
}
}
おわりに
かなり複雑なコードになってしまいました。。。
不明点や、こちらの認識が誤っている部分あれば、コメントいただければ幸いです。