ジェスチャの闘技場: GestureArena

Flutter界隈のみなさまこんにちは、師走はいかがお過ごしでしょうか。
それにしても、めちゃくちゃ寒いですね。もう布団以外全部地獄。

さて、今日は、Flutterの奥地で見つけたジェスチャにまつわる面白い仕様を紹介したいと思います。

## 悲鳴が聞こえませんか…？

FlutterはサクサクとUIが作れますね。楽しいですね。でも、作ったUIを実際に触ってみると、Flutterの中から**怒号**や**悲鳴**が聞こえることはありませんか…？**僕はあります。**

おおよそ「この**指**は私のものよ！」「違う！俺のものだ！」「キャー！」といった内容のものです。
一体何が起きているのでしょうか…？真相を求め、調査班はFlutterの奥地へと向かうことにしました。

## Flutterの奥地:タッチイベントのゆくえ

> ここは[GestureBinding](https://github.com/flutter/flutter/blob/61a0add2865c51bfee33939c1820709d1115c77d/packages/flutter/lib/src/gestures/binding.dart#L254)。タッチ操作にまつわる色々な処理が行われている、と地図には書いてあります。
> どうやら声はこの奥から聞こえているようです…

下のようなおまじないを `main.dart` でよく見かけると思います。

```{dart}
WidgetsFlutterBinding.ensureInitialized();
```

この処理が実行されると、内部的には[GestureBinding](https://github.com/flutter/flutter/blob/166f1d76de879a1977dcce84aaa22edecc15918a/packages/flutter/lib/src/gestures/binding.dart#L254)という**タッチ操作の管理者**が初期化され、Flutter Engineからタッチイベントを受け取るための[コールバック関数](https://github.com/flutter/flutter/blob/f9c4b227213fe468bf221d2413d575cd446069dd/packages/flutter/lib/src/gestures/binding.dart#L259)が設定されます。
（もちろん他にもいろんな初期化処理が走ってます）

私たちが画面をタッチするとき、まずFlutter Engineが各プラットフォーム向けのコードでタッチイベントを受け取り送信します。
これを`GestureBinding`が受け取り、普段私たちが使っている部分までタッチイベントが降りてきています。

## 当たり判定: hitTest

> `GestureBinding`には、Flutter Engineのタッチイベントがたくさん連れてこられているようです。
>
> 列に並べられたタッチイベントは、たくさんあるRenderObjectの間を順に巡り、"当たり判定"を受けていました。
> 判定を受けたタッチイベントには、たくさんの強面の闘士(`GestureRecognizer`)が割り当てられていきます。
> 一体何が始まるのでしょう…彼らはそのまま、更に奥地へと進んでいきました…

`GestureBinding`がタッチイベントを受け取ると、`WidgetsFlutterBinding`にmixinされている`RendererBinding`の[hitTest()](https://github.com/flutter/flutter/blob/166f1d76de879a1977dcce84aaa22edecc15918a/packages/flutter/lib/src/rendering/binding.dart#L527)が呼ばれ、RenderObjectのルートから順に、タッチした座標とRenderObjectの**当たり判定**を行なっていきます。

当たり判定の内容は各RenderObjectの`hitTest()`メソッドに実装を任されています。四角の当たり判定を行うものもあれば、多角形の当たり判定を行うものもあるでしょう。たまに見かける`HitTestBehavior`などはこの当たり判定の仕方を指定するものです。

そして、`hitTest()`で当たりと判定されたRenderObjectの[handleEvent()](https://github.com/flutter/flutter/blob/166f1d76de879a1977dcce84aaa22edecc15918a/packages/flutter/lib/src/gestures/binding.dart#L419)が呼び出されていきます。
例えば、[Listener](https://api.flutter.dev/flutter/widgets/Listener-class.html)や[GestureDetector](https://api.flutter.dev/flutter/widgets/GestureDetector-class.html)のRenderObjectに相当する[RenderPointerListener](https://github.com/flutter/flutter/blob/166f1d76de879a1977dcce84aaa22edecc15918a/packages/flutter/lib/src/rendering/proxy_box.dart#L2842)は、[このhandleEvent](https://github.com/flutter/flutter/blob/166f1d76de879a1977dcce84aaa22edecc15918a/packages/flutter/lib/src/rendering/proxy_box.dart#L2885)で`onPointerDown`などの馴染みのあるコールバックを呼び出しているわけです。
これがちょうど、[Listener](https://api.flutter.dev/flutter/widgets/Listener-class.html)ウィジェットの機能です。

```{dart}
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](https://api.flutter.dev/flutter/widgets/GestureDetector-class.html)ウィジェットを使うと、より複雑なタッチ・ジェスチャに対応した処理を書くことができます。

```{dart}
GestureDetector(
  onVerticalDragDown: () {
    // 縦方向にドラッグされた時の処理
  },
  onHorizontalDragDown: () {
    // 横方向にドラッグされた時の処理
  },
  child: ...,
)
```

`GestureDetector`ウィジェットにonVerticalDragDownなどのコールバックを指定した場合、対応する`GestureRecognizer`（ジェスチャ判定器）が[作成](https://github.com/flutter/flutter/blob/cf44000065/packages/flutter/lib/src/widgets/gesture_detector.dart#L1074)されます。
（ジェスチャ判定器にはTapGestureRecognizer, LongPressGestureRecognizer, HorizontalDragGestureRecognizer...など色々な種類があります）


先ほどRenderPointerListenerがonPointerDownを呼び出す様子を説明しましたが、`GestureDetector`ではここで、[タッチイベントと自分の持つ`GestureRecognizer`を関連づけ](https://github.com/flutter/flutter/blob/cf44000065/packages/flutter/lib/src/widgets/gesture_detector.dart#L1421)ます。
これにより、GestureBindingのPointerRouterという、タッチイベントに対応する処理を登録するクラスに`GestureRecognizer`が[登録](https://github.com/flutter/flutter/blob/e4a1d3e1d3354ba243f3109910bf4357e24f166b/packages/flutter/lib/src/gestures/recognizer.dart#L374)され、`GestureRecognizer`がタッチイベントを監視できるようになります。

## 闘技場: GestureArena

> タッチイベントと闘士達が入っていったのは、どうやら闘技場のようです。中からは、例の怒号や悲鳴が聞こえてきます。
> 「指をよこせぇ！」「キャー！」「ちっ…次こそは…覚えてろよ！」
> ここでは、タッチイベントをめぐって試合が行われているようでした。

`GestureBinding`には[GestureArena](https://github.com/flutter/flutter/blob/f9c4b227213fe468bf221d2413d575cd446069dd/packages/flutter/lib/src/gestures/binding.dart#L304)という闘技場があります。
この闘技場では、「誰がこのジェスチャを処理するのか」を決めるための`GestureRecognizer`同士の試合が行われます。

試合のルールは:

- Recognizerは、いつでも[敗北を宣言する(declare defeat)](https://github.com/flutter/flutter/blob/e4a1d3e1d3354ba243f3109910bf4357e24f166b/packages/flutter/lib/src/gestures/monodrag.dart#L406)ことができる。敗北を宣言した場合は、闘技場を去る
- Recognizerは、いつでも[勝利を宣言する(declare victory)](https://github.com/flutter/flutter/blob/e4a1d3e1d3354ba243f3109910bf4357e24f166b/packages/flutter/lib/src/gestures/monodrag.dart#L317)ことができる。その他のRecognizerは敗北となる
- 誰も勝利を宣言しなかった場合、[最後まで残ったRecognizerが勝利](https://github.com/flutter/flutter/blob/e7b7ebc066c1b2a5aa5c19f8961307427e0142a6/packages/flutter/lib/src/gestures/arena.dart#L251)となる

https://docs.flutter.dev/development/ui/advanced/gestures#gesture-disambiguation

それぞれのRecognizerは、先ほど関連づけたタッチイベントを監視し、自分のジェスチャであると断言(=勝利を宣言)できるまで待機します。
例えば、[DragGestureRecognizer](https://api.flutter.dev/flutter/gestures/DragGestureRecognizer-class.html)の場合は、[閾値以上TouchMoveが起こった場合](https://github.com/flutter/flutter/blob/e4a1d3e1d3354ba243f3109910bf4357e24f166b/packages/flutter/lib/src/gestures/monodrag.dart#L316)に、ドラッグジェスチャとして勝利宣言を行います。

```dart
  @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も端末ごとの値が設定される予定](https://github.com/flutter/flutter/issues/87950)です。もちろん、`MediaQuery`を使うことでこの閾値を上書きすることも可能です。
参考: https://github.com/flutter/flutter/issues/87322

## めでたしめでたし

> 闘技場から続々と負けた闘士達が出てきます。みな口々に「次こそは…」と呟いています。彼らはまた、別のタッチイベントがやってくるのを待つようです。
> しばらくして、タッチイベントと優勝した闘士が出てきました。
> お互いに幸せそうなその表情を見た調査班は満足し、Flutterの奥地を後にするのでした…

というわけで、普段私たちが触っているFlutterの奥地`GestureArena`では、どのRecognizerがタッチイベントにふさわしいかを決める勝負が、日夜開催されているのでした。

今回は割愛しましたが、[Team](https://api.flutter.dev/flutter/gestures/GestureArenaTeam-class.html)や[Captain](https://api.flutter.dev/flutter/gestures/GestureArenaTeam/captain.html)などの**団体戦**にまつわる登場人物もいて面白いので、興味のある方は深ぼってみてください。

明日はチームメンバーの @b4tchkn さんが何か書いてくれるようです。

### 過去のアドベントカレンダー

https://qiita.com/mjhd/items/e15e7d0342aca102a732