LoginSignup
16
4

More than 1 year has passed since last update.

ジェスチャの闘技場: GestureArena

Last updated at Posted at 2021-12-12

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されているRendererBindinghitTest()が呼ばれ、RenderObjectのルートから順に、タッチした座標とRenderObjectの当たり判定を行なっていきます。

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

そして、hitTest()で当たりと判定されたRenderObjectのhandleEvent()が呼び出されていきます。
例えば、ListenerGestureDetectorのRenderObjectに相当するRenderPointerListenerは、このhandleEventonPointerDownなどの馴染みのあるコールバックを呼び出しているわけです。
これがちょうど、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は、先ほど関連づけたタッチイベントを監視し、自分のジェスチャであると断言(=勝利を宣言)できるまで待機します。
例えば、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以降ではMediaQuerygestureSetting.touchSlopという値が判定に使われていて、端末ごとに値が変わります。現在はAndroidだけ端末ごとの閾値が設定されますが、iOSも端末ごとの値が設定される予定です。もちろん、MediaQueryを使うことでこの閾値を上書きすることも可能です。
参考: https://github.com/flutter/flutter/issues/87322

めでたしめでたし

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

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

今回は割愛しましたが、TeamCaptainなどの団体戦にまつわる登場人物もいて面白いので、興味のある方は深ぼってみてください。

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

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

16
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
16
4