🤙

Flutterのタッチイベント処理探訪② Gesture

2022/03/19に公開

前回の記事は↓

https://zenn.dev/fastriver/articles/3d0f93d65b7ebf

さて、UI操作においてタッチイベントの処理は非常に重要な課題です。Flutterではタッチイベントを2つのレイヤーに分けて処理しています。下位レイヤーであるPointerレイヤーについては前回の記事を参照してください。
今回は上位レイヤーのGestureレイヤーについて実装を追いながらどのような仕組みで動いているのかを見ていきたいと思います。

Gestureとは

https://medium.com/litslink/flutter-gesturedetector-86fc937aaf17 より引用

Pointerレイヤーではプラットフォームからポインターが押された/移動した/離れたときを検知することができました。しかしこのデータをそのまま使うだけでは適切なタッチイベント処理を行うことはできません。単純なタップを検知したい場合でも、PointerUpを監視するのみでは例えば指をずらしてから離した時にも反応してしまいます。
また上の画像のように実際の画面では様々なポインタの動きからユーザがどの動きを意図して行ったのかを考える必要があります。
Flutterは上のようなTap/Double Tap/Horizontal Drag/Pinch Inなどそれぞれを1つのGestureであると考え、ポインタの挙動から1つのGestureを選択しています。

ではどのように複数のGestureの候補から1つを選び出すのか。
闘技場(Arena)でGesture同士を戦わせるのです!!

https://qiita.com/mjhd/items/8e300238e494d8755b44

登場人物

闘技場(_GestureArena)

https://github.com/flutter/flutter/blob/f9c4b227213fe468bf221d2413d575cd446069dd/packages/flutter/lib/src/gestures/arena.dart#L57

プライベートクラスなのでドキュメントがない。

闘技場です。この闘技場に参加するメンバー(GestureArenaMemeber)のリストを持っています。
クラスの実装は非常にシンプルで、状態の変更などは全てGestureArenaManagerから行います。

PointerEventは指の軌跡ごとにユニークなid(pointer)を振っています。闘技場はそのpointer1つにつき1つ会場が用意されるようになっているようです。

GestureArenaManager

https://api.flutter.dev/flutter/gestures/GestureArenaManager-class.html

闘技場の作成・削除・状態変更などを管理するクラスで、GestureBinding.gestureArenaというプロパティに存在します(ややこしい)。

闘技場の勝敗判定もこのクラスが行っています。

参加者(GestureArenaMember)

https://api.flutter.dev/flutter/gestures/GestureArenaMember-class.html

闘技場の参加者になりうるinterfaceです。
闘技場で勝利/敗北が決定したときに呼ばれる関数を持つようになっています。

実装はGestureRecognizerが持ちます。

GestureRecognizer

https://api.flutter.dev/flutter/gestures/GestureRecognizer-class.html

Gestureを認識するためのクラスです。それぞれのGestureごとにこれを継承したクラスがあります。

監視対象のGestureArenaEntryのリストを持っています。

GestureArenaEntry

https://api.flutter.dev/flutter/gestures/GestureArenaEntry-class.html

あるpointerの所属するGestureArenaMemberGestureArenaManagerの情報を持つクラスです。

GestureArenaTeam

https://api.flutter.dev/flutter/gestures/GestureArenaTeam-class.html

闘技場内で複数のGestureArenaMemberがチームを組むことができます。そのチームを処理するためのクラスです。またチーム内でキャプテンを決めることもできます。

チームを組むのは特別なケースなのであまり説明しませんが、仕組みは:

  • 闘技場に同じチームのメンバーしかいなくなった場合
    • キャプテンがいればキャプテンが勝利(他のメンバーは敗北)
    • キャプテンが不在なら最初のメンバーが勝利(ほかは敗北)

というように処理されます。

PointerRouter

https://api.flutter.dev/flutter/gestures/PointerRouter-class.html

闘技場とは別で動いているものですが、特定のPointerEventがきたときにGestureRecognizerにそれを送信する場所(関数)を登録しておくクラスです。ほとんど闘技場単位で送信しているものと思われます。

1.闘技場の開催

ツリーの構築

Gestureの闘技場がどのように動くのかを見るために、図のようなツリーに2つのGestureDetectorを含んだアプリの例を考えることにしましょう。
GestureDetectorをWidgetとして使うと、WidgetツリーにはListenerが追加されます。
(実際に追加しているのはGestureDetectorの低レイヤを提供しているRawGestureDetector(のState)のbuild部分になります。)

  Widget build(BuildContext context) {
    Widget result = Listener(
      onPointerDown: _handlePointerDown,
      behavior: widget.behavior ?? _defaultBehavior,
      child: widget.child,
    );
    if (!widget.excludeFromSemantics) {
      result = _GestureSemantics(
        behavior: widget.behavior ?? _defaultBehavior,
        assignSemantics: _updateSemanticsForRenderObject,
        child: result,
      );
    }
    return result;
  }

https://api.flutter.dev/flutter/widgets/RawGestureDetectorState/build.html

このため2つのGestureDetectorを含むツリーは2つのListener(正しくはListenerと対応するRenderObjectであるRenderPointerListener)のあるRenderツリーを生成します。

Recognizerの参加

次にPointerDownEventが流れてきたときのことを考えてみます。この辺りは以下の部分を踏襲しています。
もちろんこのイベントの位置は2つのGestureDetectorの反応する位置だとします。

https://zenn.dev/fastriver/articles/3d0f93d65b7ebf#pointereventとは

同一pointerでは

PointerDown -> PointerMove -> PointerUp

という一連の順番で流れてくることがわかっているため、PointerDownEventの持つpointerは新規のポインターであるわけです。
するとGestureレイヤーは闘技場を作る準備を行います。

図を見ると、PointerEventはPointerレイヤーに沿ってRenderツリーを下から遡って順にhandleEvent()を呼び出していきます。
Listenerはそれに合わせて登録されたonPointerDownコールバックを呼び出します。

GestureDetectorの場合はRawGestureDetectorState._handlePointerDown()が登録されています。

  void _handlePointerDown(PointerDownEvent event) {
    assert(_recognizers != null);
    for (final GestureRecognizer recognizer in _recognizers!.values)
      recognizer.addPointer(event);
  }

https://github.com/flutter/flutter/blob/de4eb162549a2b479bb547d1e86b43e66c65bd57/packages/flutter/lib/src/widgets/gesture_detector.dart#L1432

関数内で_recognizersごとにGestureRecognizer.addPointer()を呼び出していますね。_recognizersにはGestureDetectorに指定したコールバックを元に作られたGestureRecognizerたちが入っています。詳しくは

GestureDetector.build()
-> RawGestureDetectorState.initState()
-> RawGestureDetectorState._syncAll()

を見るとRecognizerの生成状況がわかると思います。

GestureRecognizer.addPointer()の先は種類によって異なるのですが、例えばOneSequenceGestureRecognizerでは

GestureRecognizer.addPointer()
-> OneSequenceGestureRecognizer.addAllowedPointer()
-> OneSequenceGestureRecognizer.startTrackingPointer()
-> OneSequenceGestureRecognizer._addPointerToArena()

という順に呼ばれていきます。この流れの中でGestureRecognizerが闘技場とPointerRouterに登録されていることがわかります。

  GestureArenaEntry _addPointerToArena(int pointer) {
    if (_team != null)
      return _team!.add(pointer, this);
    return GestureBinding.instance.gestureArena.add(pointer, this);
  }

  void startTrackingPointer(int pointer, [Matrix4? transform]) {
    GestureBinding.instance.pointerRouter.addRoute(pointer, handleEvent, transform);
    _trackedPointers.add(pointer);
    assert(!_entries.containsValue(pointer));
    _entries[pointer] = _addPointerToArena(pointer);
  }

ちなみにどこで闘技場が新たに作られるのかというと、新規のpointerでGestureArenaManager.add()を呼び出した場合になります。

  GestureArenaEntry add(int pointer, GestureArenaMember member) {
    final _GestureArena state = _arenas.putIfAbsent(pointer, () {
      assert(_debugLogDiagnostic(pointer, '★ Opening new gesture arena.'));
      return _GestureArena();
    });
    state.add(member);
    assert(_debugLogDiagnostic(pointer, 'Adding: $member'));
    return GestureArenaEntry._(this, pointer, member);
  }

星ではしゃぐなよ

この登録作業が該当する全てのRecognizerにより行われることで、闘技場の開催準備が整いました。

闘技場の締切

PointerEventはRenderツリーを全て走査したあと、GestureBinding.handleEvent()を呼び出します。

void handleEvent(PointerEvent event, HitTestEntry entry) {
  pointerRouter.route(event);
  if (event is PointerDownEvent) {
    gestureArena.close(event.pointer);
  } else if (event is PointerUpEvent) {
    gestureArena.sweep(event.pointer);
  } else if (event is PointerSignalEvent) {
    pointerSignalResolver.resolve(event);
  }
}

https://api.flutter.dev/flutter/gestures/GestureBinding/handleEvent.html

最初の行はあとに回すとして、PointerDownEventが回ってくるとGestureArenaManager.close()が呼ばれ、当該の闘技場の参加が締め切られます。
ここから真のバトルが始まるわけです――

2.勝敗の判定

後続イベントの受け取り

(1つ目のイベントも該当しますが)後続の同一pointerを持つPointerEventがやってくると、GestureBinding.handleEvent()内で呼ばれるPointerRouter.route()により登録されているコールバック、つまりRecognizerのhandleEvent()に流されます。
各Recognizerはここに流れてきたPointerEventを見ながら自分のジェスチャーなのかを考える材料にします。

勝敗の宣言とコールバックの呼び出し

闘技場に参加しているRecognizerは好きなタイミングで勝利宣言または敗北宣言をすることができます。
例えばOneSequenceGestureRecognizerでは、resolve()GestureDisposition.accepted/GestureDisposition.regectedを渡すことで宣言しています。

void resolve(GestureDisposition disposition) {
  final List<GestureArenaEntry> localEntries = List<GestureArenaEntry>.of(_entries.values);
  _entries.clear();
  for (final GestureArenaEntry entry in localEntries)
    entry.resolve(disposition);
}

https://api.flutter.dev/flutter/gestures/OneSequenceGestureRecognizer/resolve.html

ここではRecognizerの持つGestureArenaEntryresolveを経由して闘技場に通知するようになっています。

void resolve(GestureDisposition disposition) {
  _arena._resolve(_pointer, _member, disposition);
}

https://api.flutter.dev/flutter/gestures/GestureArenaEntry/resolve.html

あくまでRecognizerができるのは宣言のみで、実際の勝敗を決定するのは呼び出された闘技場とManagerの仕事です。

また、Recognizerの仕事としてWidgetなどから渡されたコールバックの呼び出しがあります。これは私達が作成するonTapDownなどのことで、VerticalDragジェスチャーなら

  • onVerticalDragStart
  • onVerticalDragDown
  • onVerticalDragUpdate
  • onVerticalDragCancel
  • onVerticalDragEnd

のコールバックを適当なタイミングで呼び出すわけです。コールバックを呼び出すのにはGestureRecognizer.invokeCallback()を使います。

https://api.flutter.dev/flutter/gestures/GestureRecognizer/invokeCallback.html

勝敗判定1.誰かが勝利宣言した場合

Recognizerの仕事がわかったところで闘技場の勝敗判定条件を一つづつ見ていきましょう。

まず考えられるのは闘技場にいる誰かがresolve(GestureDisposition.accepted)を呼び出して勝利宣言した場合です。

上の図のようにRecognizerAがあるhandleEvent()のタイミングで勝利宣言した場合を考えます。resolve(GestureDisposition.accepted)を呼び出すと前に話したようにGestureArenaManager._resolve()が呼ばれます。

  void _resolve(int pointer, GestureArenaMember member, GestureDisposition disposition) {
    final _GestureArena? state = _arenas[pointer];
    if (state == null)
      return; // This arena has already resolved.
    assert(_debugLogDiagnostic(pointer, '${ disposition == GestureDisposition.accepted ? "Accepting" : "Rejecting" }: $member'));
    assert(state.members.contains(member));
    if (disposition == GestureDisposition.rejected) {
      state.members.remove(member);
      member.rejectGesture(pointer);
      if (!state.isOpen)
        _tryToResolveArena(pointer, state);
    } else {
      assert(disposition == GestureDisposition.accepted);
      if (state.isOpen) {
        state.eagerWinner ??= member;
      } else {
        assert(_debugLogDiagnostic(pointer, 'Self-declared winner: $member'));
        _resolveInFavorOf(pointer, state, member);
      }
    }
  }

https://github.com/flutter/flutter/blob/7e9793dee1b85a243edd0e06cb1658e98b077561/packages/flutter/lib/src/gestures/arena.dart#L206

ごちゃごちゃ色々やっていますが、dispositionacceptedであり、state.isOpen == falseなので_resolveInFavorOf()が呼ばれるだけです。

  void _resolveInFavorOf(int pointer, _GestureArena state, GestureArenaMember member) {
    assert(state == _arenas[pointer]);
    assert(state != null);
    assert(state.eagerWinner == null || state.eagerWinner == member);
    assert(!state.isOpen);
    _arenas.remove(pointer);
    for (final GestureArenaMember rejectedMember in state.members) {
      if (rejectedMember != member)
        rejectedMember.rejectGesture(pointer);
    }
    member.acceptGesture(pointer);
  }

https://github.com/flutter/flutter/blob/7e9793dee1b85a243edd0e06cb1658e98b077561/packages/flutter/lib/src/gestures/arena.dart#L254

この関数では指定されたメンバー(=勝利宣言したメンバー)のみGestureRecognizer.acceptGesture()を呼び、他はrejectGesture()を呼ぶようになっていますね。

  • acceptGesture(): 勝利確定時に呼ばれる
  • rejectGesture(): 敗北確定時に呼ばれる

ということで、勝者が最初に勝利宣言したメンバーになりました!
勝利・敗北通知を受け取ったそれぞれのRecognizerは適当なコールバックを呼び出すなどして残務処理を行うことになります。

勝敗判定2.メンバーが1人になった場合

では他のメンバーが敗北宣言して闘技場に1人残った場合はどうなるでしょうか?
その場合も同様に宣言時にGestureArenaManager._resolve()が呼ばれます。

if (disposition == GestureDisposition.rejected) {
  state.members.remove(member);
  member.rejectGesture(pointer);
  if (!state.isOpen)
    _tryToResolveArena(pointer, state);
}

敗北宣言をしたメンバーは闘技場から退場させられ、敗北通知が呼ばれます。またメンバーが更新されたことで_tryToResolveArena()を呼んで勝敗判定ができるかを確かめるようになっています。

  void _tryToResolveArena(int pointer, _GestureArena state) {
    //...
    if (state.members.length == 1) {
      scheduleMicrotask(() => _resolveByDefault(pointer, state));
    } //...
  }
  
  void _resolveByDefault(int pointer, _GestureArena state) {
    //...
    _arenas.remove(pointer);
    assert(_debugLogDiagnostic(pointer, 'Default winner: ${state.members.first}'));
    state.members.first.acceptGesture(pointer);
  }

https://github.com/flutter/flutter/blob/7e9793dee1b85a243edd0e06cb1658e98b077561/packages/flutter/lib/src/gestures/arena.dart#L228

最終的にはリストの最初のメンバー、つまり最後まで残ったメンバーが勝利していますね。

勝敗判定3.誰も勝利せずポインターが離れた場合

上ではメンバーが能動的に勝利・敗北宣言を行うことで勝者が決定しました。宣言を行わないままポインターが離れてしまった場合はどのように勝者が決まるのでしょうか?

pointerが離れたとき、最後に送られてくるのはPointerUpEventです。これがGestureBinding.handleEvent()まで到達すると、GestureArenaManager.sweep()が呼ばれます。

void handleEvent(PointerEvent event, HitTestEntry entry) {
  pointerRouter.route(event);
  if (event is PointerDownEvent) {
    gestureArena.close(event.pointer);
  } else if (event is PointerUpEvent) {
    gestureArena.sweep(event.pointer);
  } else if (event is PointerSignalEvent) {
    pointerSignalResolver.resolve(event);
  }
}
void sweep(int pointer) {
  final _GestureArena? state = _arenas[pointer];
  //...
  _arenas.remove(pointer);
  if (state.members.isNotEmpty) {
    // First member wins.
    assert(_debugLogDiagnostic(pointer, 'Winner: ${state.members.first}'));
    state.members.first.acceptGesture(pointer);
    // Give all the other members the bad news.
    for (int i = 1; i < state.members.length; i++)
      state.members[i].rejectGesture(pointer);
  }
}

https://api.flutter.dev/flutter/gestures/GestureArenaManager/sweep.html

はい、前項で敗北宣言が行われた場合と同様にリストの最初のメンバーが勝利となり、他のメンバーは敗北通知を受け取るようになっています。

3.闘技場の閉場

闘技場内で勝者が決定されると、その闘技場はGestureArenaManager.remove()で削除されます(先程からちらほらコード内に出てきている)。
一方PointerRouterの方は闘技場と同じタイミングで削除されるとは限りません。これは闘技場での勝利後もonDragEnd()などのコールバックを処理するためにpointerを取得したいからです。こちらはPointerUpEventが処理されたのちに削除されることになります。

終わり

普段から何気なく使っているFlutterアプリのタッチイベント。裏側では決闘者たちが熱き戦いを繰り広げていたのでした。彼らと心を通わせることができればジェスチャーの挙動に惑わされることも減るのではないでしょうか。

Discussion