Flutterのタッチイベント処理探訪② Gesture
前回の記事は↓
さて、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同士を戦わせるのです!!
登場人物
闘技場(_GestureArena)
プライベートクラスなのでドキュメントがない。
闘技場です。この闘技場に参加するメンバー(GestureArenaMemeber
)のリストを持っています。
クラスの実装は非常にシンプルで、状態の変更などは全てGestureArenaManager
から行います。
PointerEventは指の軌跡ごとにユニークなid(pointer)を振っています。闘技場はそのpointer1つにつき1つ会場が用意されるようになっているようです。
GestureArenaManager
闘技場の作成・削除・状態変更などを管理するクラスで、GestureBinding.gestureArena
というプロパティに存在します(ややこしい)。
闘技場の勝敗判定もこのクラスが行っています。
参加者(GestureArenaMember)
闘技場の参加者になりうるinterfaceです。
闘技場で勝利/敗北が決定したときに呼ばれる関数を持つようになっています。
実装はGestureRecognizer
が持ちます。
GestureRecognizer
Gestureを認識するためのクラスです。それぞれのGestureごとにこれを継承したクラスがあります。
監視対象のGestureArenaEntry
のリストを持っています。
GestureArenaEntry
あるpointerの所属するGestureArenaMember
とGestureArenaManager
の情報を持つクラスです。
GestureArenaTeam
闘技場内で複数のGestureArenaMember
がチームを組むことができます。そのチームを処理するためのクラスです。またチーム内でキャプテンを決めることもできます。
チームを組むのは特別なケースなのであまり説明しませんが、仕組みは:
- 闘技場に同じチームのメンバーしかいなくなった場合
- キャプテンがいればキャプテンが勝利(他のメンバーは敗北)
- キャプテンが不在なら最初のメンバーが勝利(ほかは敗北)
というように処理されます。
PointerRouter
闘技場とは別で動いているものですが、特定の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;
}
このため2つのGestureDetector
を含むツリーは2つのListener
(正しくはListener
と対応するRenderObjectであるRenderPointerListener
)のあるRenderツリーを生成します。
Recognizerの参加
次にPointerDownEvent
が流れてきたときのことを考えてみます。この辺りは以下の部分を踏襲しています。
もちろんこのイベントの位置は2つのGestureDetector
の反応する位置だとします。
同一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);
}
関数内で_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);
}
}
最初の行はあとに回すとして、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);
}
ここではRecognizerの持つGestureArenaEntry
のresolve
を経由して闘技場に通知するようになっています。
void resolve(GestureDisposition disposition) {
_arena._resolve(_pointer, _member, disposition);
}
あくまでRecognizerができるのは宣言のみで、実際の勝敗を決定するのは呼び出された闘技場とManagerの仕事です。
また、Recognizerの仕事としてWidgetなどから渡されたコールバックの呼び出しがあります。これは私達が作成するonTapDown
などのことで、VerticalDragジェスチャーなら
onVerticalDragStart
onVerticalDragDown
onVerticalDragUpdate
onVerticalDragCancel
onVerticalDragEnd
のコールバックを適当なタイミングで呼び出すわけです。コールバックを呼び出すのにはGestureRecognizer.invokeCallback()
を使います。
勝敗判定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);
}
}
}
ごちゃごちゃ色々やっていますが、disposition
はaccepted
であり、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);
}
この関数では指定されたメンバー(=勝利宣言したメンバー)のみ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);
}
最終的にはリストの最初のメンバー、つまり最後まで残ったメンバーが勝利していますね。
勝敗判定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);
}
}
はい、前項で敗北宣言が行われた場合と同様にリストの最初のメンバーが勝利となり、他のメンバーは敗北通知を受け取るようになっています。
3.闘技場の閉場
闘技場内で勝者が決定されると、その闘技場はGestureArenaManager.remove()
で削除されます(先程からちらほらコード内に出てきている)。
一方PointerRouter
の方は闘技場と同じタイミングで削除されるとは限りません。これは闘技場での勝利後もonDragEnd()
などのコールバックを処理するためにpointerを取得したいからです。こちらはPointerUpEvent
が処理されたのちに削除されることになります。
終わり
普段から何気なく使っているFlutterアプリのタッチイベント。裏側では決闘者たちが熱き戦いを繰り広げていたのでした。彼らと心を通わせることができればジェスチャーの挙動に惑わされることも減るのではないでしょうか。
Discussion