Flutter界隈のみなさまこんにちは、師走はいかがお過ごしでしょうか。
それにしても、めちゃくちゃ寒いですね。もう布団以外全部地獄。
さて、今日は、Flutterの奥地で見つけたジェスチャにまつわる面白い仕様を紹介したいと思います。
悲鳴が聞こえませんか…?
FlutterはサクサクとUIが作れますね。楽しいですね。でも、作ったUIを実際に触ってみると、Flutterの中から怒号や悲鳴が聞こえることはありませんか…?僕はあります。
おおよそ「この指は私のものよ!」「違う!俺のものだ!」「キャー!」といった内容のものです。
一体何が起きているのでしょうか…?真相を求め、調査班はFlutterの奥地へと向かうことにしました。
Flutterの奥地:タッチイベントのゆくえ
ここはGestureBinding。タッチ操作にまつわる色々な処理が行われている、と地図には書いてあります。
どうやら声はこの奥から聞こえているようです…
下のようなおまじないを main.dart
でよく見かけると思います。
WidgetsFlutterBinding.ensureInitialized();
この処理が実行されると、内部的にはGestureBindingというタッチ操作の管理者が初期化され、Flutter Engineからタッチイベントを受け取るためのコールバック関数が設定されます。
(もちろん他にもいろんな初期化処理が走ってます)
私たちが画面をタッチするとき、まずFlutter Engineが各プラットフォーム向けのコードでタッチイベントを受け取り送信します。
これをGestureBinding
が受け取り、普段私たちが使っている部分までタッチイベントが降りてきています。
当たり判定: hitTest
GestureBinding
には、Flutter Engineのタッチイベントがたくさん連れてこられているようです。列に並べられたタッチイベントは、たくさんあるRenderObjectの間を順に巡り、"当たり判定"を受けていました。
判定を受けたタッチイベントには、たくさんの強面の闘士(GestureRecognizer
)が割り当てられていきます。
一体何が始まるのでしょう…彼らはそのまま、更に奥地へと進んでいきました…
GestureBinding
がタッチイベントを受け取ると、WidgetsFlutterBinding
にmixinされているRendererBinding
のhitTest()が呼ばれ、RenderObjectのルートから順に、タッチした座標とRenderObjectの当たり判定を行なっていきます。
当たり判定の内容は各RenderObjectのhitTest()
メソッドに実装を任されています。四角の当たり判定を行うものもあれば、多角形の当たり判定を行うものもあるでしょう。たまに見かけるHitTestBehavior
などはこの当たり判定の仕方を指定するものです。
そして、hitTest()
で当たりと判定されたRenderObjectのhandleEvent()が呼び出されていきます。
例えば、ListenerやGestureDetectorのRenderObjectに相当するRenderPointerListenerは、このhandleEventでonPointerDown
などの馴染みのあるコールバックを呼び出しているわけです。
これがちょうど、Listenerウィジェットの機能です。
class RenderPointerListener extends RenderProxyBoxWithHitTestBehavior {
...
@override
void handleEvent(PointerEvent event, HitTestEntry entry) {
assert(debugHandleEvent(event, entry));
if (event is PointerDownEvent)
return onPointerDown?.call(event);
if (event is PointerMoveEvent)
return onPointerMove?.call(event);
if (event is PointerUpEvent)
return onPointerUp?.call(event);
if (event is PointerHoverEvent)
return onPointerHover?.call(event);
if (event is PointerCancelEvent)
return onPointerCancel?.call(event);
if (event is PointerSignalEvent)
return onPointerSignal?.call(event);
}
...
}
// https://github.com/flutter/flutter/blob/166f1d76de879a1977dcce84aaa22edecc15918a/packages/flutter/lib/src/rendering/proxy_box.dart#L2885
Listener
ウィジェット以外にも、GestureDetectorウィジェットを使うと、より複雑なタッチ・ジェスチャに対応した処理を書くことができます。
GestureDetector(
onVerticalDragDown: () {
// 縦方向にドラッグされた時の処理
},
onHorizontalDragDown: () {
// 横方向にドラッグされた時の処理
},
child: ...,
)
GestureDetector
ウィジェットにonVerticalDragDownなどのコールバックを指定した場合、対応するGestureRecognizer
(ジェスチャ判定器)が作成されます。
(ジェスチャ判定器にはTapGestureRecognizer, LongPressGestureRecognizer, HorizontalDragGestureRecognizer...など色々な種類があります)
先ほどRenderPointerListenerがonPointerDownを呼び出す様子を説明しましたが、GestureDetector
ではここで、タッチイベントと自分の持つGestureRecognizer
を関連づけます。
これにより、GestureBindingのPointerRouterという、タッチイベントに対応する処理を登録するクラスにGestureRecognizer
が登録され、GestureRecognizer
がタッチイベントを監視できるようになります。
闘技場: GestureArena
タッチイベントと闘士達が入っていったのは、どうやら闘技場のようです。中からは、例の怒号や悲鳴が聞こえてきます。
「指をよこせぇ!」「キャー!」「ちっ…次こそは…覚えてろよ!」
ここでは、タッチイベントをめぐって試合が行われているようでした。
GestureBinding
にはGestureArenaという闘技場があります。
この闘技場では、「誰がこのジェスチャを処理するのか」を決めるためのGestureRecognizer
同士の試合が行われます。
試合のルールは:
- Recognizerは、いつでも敗北を宣言する(declare defeat)ことができる。敗北を宣言した場合は、闘技場を去る
- Recognizerは、いつでも勝利を宣言する(declare victory)ことができる。その他のRecognizerは敗北となる
- 誰も勝利を宣言しなかった場合、最後まで残ったRecognizerが勝利となる
それぞれのRecognizerは、先ほど関連づけたタッチイベントを監視し、自分のジェスチャであると断言(=勝利を宣言)できるまで待機します。
例えば、DragGestureRecognizerの場合は、閾値以上TouchMoveが起こった場合に、ドラッグジェスチャとして勝利宣言を行います。
@override
bool _hasSufficientGlobalDistanceToAccept(PointerDeviceKind pointerDeviceKind, double? deviceTouchSlop) {
// 閾値(slop)の値は、入力デバイスによって異なるが、タッチではkTouchSlop=18.0が現在使われている(変わる可能性あり)
return _globalDistanceMoved.abs() > computeHitSlop(pointerDeviceKind, gestureSettings);
}
// https://github.com/flutter/flutter/blob/e4a1d3e1d3354ba243f3109910bf4357e24f166b/packages/flutter/lib/src/gestures/monodrag.dart#L542
ジェスチャは、タッチが開始した時点では曖昧で、タップなのかロングタップなのか、横ドラッグなのか縦ドラッグなのか、すぐには判定できません。
ユーザが指を動かし、情報が増えてからようやく断定ができます。
その曖昧さの解消を汎用的に行うため、闘技場で戦う必要があったのです。
実は、先程のDragGestureDetector
の例だと、18px
が閾値として設定されているため、複数のジェスチャが登録されている場合、ドラッグ処理の開始が18px遅れます。
複数のジェスチャが登録されている場合、Arenaで戦いの勝敗がつくまでジェスチャの判定が遅れるわけです。
なのでGestureDetector
をピクセルセンシティブな処理に使うと、ズレが生じたり、微妙に反応が悪かったりと、問題が起こる場合があります。
この場合は、AbsorbPointer
, IgnorePointer
などを活用して複数のジェスチャが登録されない状況を作るか、GestureArena
を介さずタッチイベントを処理できるListener
ウィジェットを使うことを検討すると良いかと思います。
(ただし、GestureDetector
は内部でSemanticsの対応も行なっているため、Listener
を使った場合はこれを補う方法も一緒に検討するべきだと思います)
2022年7月28日追記:
閾値が18px
と書きましたが、Flutter 3.0.0以降ではMediaQuery
のgestureSetting.touchSlop
という値が判定に使われていて、端末ごとに値が変わります。現在はAndroidだけ端末ごとの閾値が設定されますが、iOSも端末ごとの値が設定される予定です。もちろん、MediaQuery
を使うことでこの閾値を上書きすることも可能です。
参考: https://github.com/flutter/flutter/issues/87322
めでたしめでたし
闘技場から続々と負けた闘士達が出てきます。みな口々に「次こそは…」と呟いています。彼らはまた、別のタッチイベントがやってくるのを待つようです。
しばらくして、タッチイベントと優勝した闘士が出てきました。
お互いに幸せそうなその表情を見た調査班は満足し、Flutterの奥地を後にするのでした…
というわけで、普段私たちが触っているFlutterの奥地GestureArena
では、どのRecognizerがタッチイベントにふさわしいかを決める勝負が、日夜開催されているのでした。
今回は割愛しましたが、TeamやCaptainなどの団体戦にまつわる登場人物もいて面白いので、興味のある方は深ぼってみてください。
明日はチームメンバーの @b4tchkn さんが何か書いてくれるようです。
過去のアドベントカレンダー