※記事内のコードは Flutter 3.24.3 より抜粋
【概要】
ジェスチャー検出の仕組み
Futter では、ジェスチャーは以下の順番で処理されている。
Pointer Event の取得
Flutterは、画面上でユーザが行ったタップ、ドラッグ、スワイプなどの操作を Pointer Event として収集している。
Pointer Event には下のような種類がある。
- PointerDown
- PointerMove
- PointerUp
- PointerCancel
この時点におけるユーザ操作は、単なるイベントであり、まだジェスチャーとしては認識されていない。
「指が触れた」「指が離れた」などの画面上の操作は、Flutterで Pointer Event として発生する
Hit Test
画面上の座標に基づき、発生した Pointer Event をどのウィジェットが処理するかを決定する。
これにより Pointer Event が特定のウィジェットと紐付けられる。
この過程は Hit Test と呼ばれ、 Render Tree と呼ばれるツリー構造上で「親から子ウィジェットの方向」へ順次実行される。
Pointer Event は Hit Test によって特定のウィジェットに紐づけられる
ジェスチャーの認識
Flutter 上で発生した「指が画面に触れた」「指が画面から離れた」などの Pointer Event は、単なるイベントであり、これをアプリケーション上の操作、すなわち ジェスチャー として認識する必要がある。
例えば、PointerDown
→ PointerMove
→ PointerUp
などの一連の複数の Pointer Event は、スワイプとして認識されることで、イベントからジェスチャーに昇格する。
Flutterには既に定義されたジェスチャーが複数存在する。
引用元:https://www.kodeco.com/29002200-creating-custom-gestures-in-flutter
ユーザ操作が特定のジェスチャーとして認識されることで、それぞれのジェスチャーに対して異なるコールバックを設定することができる。
ジェスチャーの競合
複数のイベントが PointerDown
→ PointerUp
の順に発生した際、一般的にこれは「タップ」として認識される。
一方で PointerDown
→ PointerUp
が短時間の間に2回連続した場合「ダブルタップ」として認識する必要がある。
この時、発生する一連の Pointer Event は、単一のジェスチャー(操作)として認識される必要がある。
ここで登場するのがジェスチャー同士の競合という概念である。ジェスチャー は アリーナ と呼ばれる闘技場(arena=闘技場)においてそれぞれが競い合い、勝者と決定する。
このアリーナは単なる比喩ではなく、実際に _GestureArena
というクラス名で実装されている。
アリーナでは参加者による エントリー が行われる。
エントリー(参加)するのは Gesture Recognizer である。
アリーナにおける「勝利」はFlutter上で「特定のジェスチャーとして認識される」ことを意味する。反対に「敗北」は一連の Pointer Event がジェスチャーとして扱われないことを意味する。
Gesture Recognizer には
などの種類がある。
コールバックの実行
画面上のユーザ操作が特定のジェスチャーとして認識されると、GestureDetector
でそれぞれのジェスチャーに対して紐づけられたコールバックが実行される。
GestureDetector(
onTap: () {},
onTapDown: (TapDownDetails details) {},
onTapUp: (TapUpDetails details) {},
onTapCancel: () {},
)
【詳細】
Pointers & Events
Flutterでは、ユーザーが画面上で行うタッチやマウスの操作を Pointer として扱う。
そして、これらの操作が内部で処理される際は Pointer Event が発生する。
実装では PointerEvent
クラスとして表現されている。
PointerEvent
PointerEvent
クラスはabstract
クラスで、下記のクラス群が拡張(extends
)している。
abstract class PointerEvent {}
// デバイスが Pointer の追跡を開始した瞬間に発生
// 指がまだ画面に触れていない可能性もある
class PointerAddedEvent extends PointerEvent {}
// 指が画面に触れた瞬間に発生
// タッチの開始を意味する
class PointerDownEvent extends PointerEvent {}
// 指が画面上を移動している間、連続的に発生
// 移動の軌跡が追跡できる
class PointerMoveEvent extends PointerEvent {}
// 指が画面から離れた瞬間に発生
// タッチの終了を意味する
class PointerUpEvent extends PointerEvent {}
// タッチイベントが中断された場合に発生
// 例: 電話の着信や他のアプリがフォーカスを奪ったとき
class PointerCancelEvent extends PointerEvent {}
// マウスカーソルがウィジェットの上に乗ったとき
// タッチデバイス(`PointerDeviceKind.touch`)では発生しない
class PointerHover extends PointerEvent {}
// マウスホイールの回転によるスクロール操作などのポインター(マウスやタッチデバイスなど)から発生する離散的な信号を表すイベント
// ポインター自体の状態(位置やボタンの押下状態など)を変化させることなく発生し、連続的なイベントの文脈で解釈する必要がない単発のイベント
class PointerSignal extends PointerEvent {}
各イベントは連続的に発生するシーケンスである。
また PointerEvent
は、 ID(識別子)、座標、デバイスなどの属性を持つ。
pointer(ID)
PointerEvent を区別するための識別子。
abstract class PointerEvent {
// 発生した PointerEvent が、どのポインタに紐づいているか
// 2本指の操作では、それぞれのポインタを別々のIDで追跡する
// または、一連の PointerDown, PointerMove, PointerUp イベントを、同じIDで関連付ける
int pointer = 0;
}
座標
操作が画面上のどの位置で発生したかを表す座標。
abstract class PointerEvent {
// 現在のイベントが発生したグローバル座標(スクリーン全体での位置)
Offset position = Offset.zero;
// 前回の PointerEvent からの相対的な位置の変化
// 操作開始座標を起点としてどの方向に Pointer が移動したかを判断する
Offset delta = Offset.zero;
// 座標系を変換するための行列
// スクリーン座標変換とローカル座標変換に使用される
Matrix4? transform;
}
デバイス
abstract class PointerEvent {
// デバイスの種類
PointerDeviceKind kind = PointerDeviceKind.touch;
// デバイスを識別するID
int device = 0;
}
enum の PointerDeviceKind
で表現される。
enum PointerDeviceKind {
// タッチ(指で画面に触れる操作)
touch,
// マウス(クリックやドラッグ操作)
mouse,
// スタイラス(タッチペン操作用のデバイス)
stylus,
// スタイラスの逆側(消しゴム機能)
invertedStylus,
// トラックパッド
trackpad,
// 不明
unknown,
}
Hit Test
ユーザーの操作をどのウィジェットが処理するべきかを決定するプロセス。
Render Tree(RenderObject
のツリー)上で行われ、Pointer Event を適切なウィジェットに紐付ける。
具体的には、画面上でユーザによる Pointer の操作が行われると、Flutter は PointerEvent
オブジェクトを生成する。この PointerEvent
に座標情報(position
)が含まれている。
abstract class PointerEvent {
Offset position = Offset.zero;
}
Hit Test は親から子の方向へ向かって実行されるため、まず初めは、ルートに位置する RenderObject
が対象となる。
RenderObject
は abstract
クラスの HitTestable
で定義された hitTest()
を拡張(extends
)している。
Hit Test 時にはこの hitTest()
が呼ばれる。
abstract class HitTestable {
void hitTest(HitTestResult result, Offset position);
}
class MyRenderObject extends RenderBox {
@override
void hitTest(BoxHitTestResult result, {required Offset position}) {
// Pointer の座標が自身のウィジェット領域内かを検証
if (position.dx >= 0 &&
position.dx < size.width &&
position.dy >= 0 &&
position.dy < size.height) {
// Hit Test 結果が true の場合、 HitTestResult に自身を追加する
result.add(BoxHitTestEntry(this, position));
}
}
}
Hit Test の結果がtrue
の場合、HitTestResult
で定義された add()
が呼ばれ、テストをパスした TestEntry
だけがリストとして保持される。
abstract class HitTestTarget {
void handleEvent(PointerEvent event, HitTestEntry entry);
}
class HitTestEntry {
HitTestEntry(this.target);
final HitTestTarget target;
}
class HitTestResult {
Iterable<HitTestEntry> get path => _path;
final List<HitTestEntry> _path;
void add(HitTestEntry entry) {
_path.add(entry);
}
}
Hit Test が一通り完了すると、HitTestResult
に格納された HitTestEntry
では、Pointer Event が順次処理される。
具体的には、各 HitTestEntry
に関連付けられた HitTestTarget
の handleEvent()
が呼び出される。
ここで重要なのが、リスト内の HitTestEntry
はリストに追加された順番とは逆順で呼び出されることである。つまり、子ウィジェットから親ウィジェットへと遡る形で handleEvent()
が実行される。
Hit Test は親から子に向かって実行され、 handleEvent()
は子から親に向かって実行される。
abstract class HitTestTarget {
void handleEvent(PointerEvent event, HitTestEntry entry);
}
class MyRenderObject extends RenderBox {
@override
bool hitTest(BoxHitTestResult result, {required Offset position}) {
}
@override
void handleEvent(PointerEvent event, HitTestEntry entry) {
// Pointer Event の処理
print('Pointer event received: $event');
}
}
このメカニズムは GestureDetector
を別の GestureDetector
でラップするなどのネストが発生した場面における Pointer Event の伝播に重要な影響を与える。
ここの挙動を制御するには、GestureDetector
の behavior
を設定する必要がある。
Gesture Arena
ジェスチャーの競技場(アリーナ)
FLutter公式サイトでは Gesture disambiguation (ジェスチャーの曖昧さ回避)というセクションで説明されている。
また Gesture Arena は、YouTubeの公式Flutterチャンネルの Decoding Flutter というシリーズの一つとしても取り上げられている。
Flutterは、流れていく Pointer Event のストリームのすべてを監視(listen)していて、TapGestureRecognizer
、LongPressGestureRecognizer
、PanGestureRecognizer
などの特定のジェスチャとして認識しようとする。
Arena はユーザ操作が複数のジェスチャー間で競合する際に、どのジェスチャーとしてハンドリングするかを決定する仕組みである。
エントリ
Arena にエントリするのは GestureArenaMember
である。
abstract class GestureArenaMember {}
ただし、GestureArenaMember
の実体は GestureRecognizer
。
abstract class GestureRecognizer extends GestureArenaMember {}
エントリは _GestureArene
の add()
によって行われる。
class _GestureArena {
final List<GestureArenaMember> members = <GestureArenaMember>[];
void add(GestureArenaMember member) {
members.add(member);
}
}
勝敗
Gesture Arena には 2つのルール が存在する。
At any time, a recognizer can eliminate itself and leave the arena. If there's only one recognizer left in the arena, that recognizer wins.
いつでも敗北を宣言してアリーナを退場できる。最後に残った一つの Recognizer が勝者になる。
At any time, a recognizer can declare itself the winner, causing all of the remaining recognizers to lose.
いつでも勝利を宣言することができる。それにより、他の Recognizer は敗者になる。
これは GestureArenaMember
に定義された2つのメソッドにより実現される。
abstract class GestureRecognizer extends GestureArenaMember {}
abstract class GestureArenaMember {
void acceptGesture(int pointer);
void rejectGesture(int pointer);
}
つまり Arena において GestureRecognizer
は、勝利宣言を acceptGesture()
によって行う。
勝利した GestureRecognizer
は、 Pointer Event を自分のジェスチャーとしてハンドリング処理する権利を獲得する。
一方、敗北宣言は rejectGesture()
によって行い、敗北した GestureRecognizer
は受信した Pointer Event をハンドリング処理しない。
acceptGesture()
: 勝利宣言時に呼ばれる
rejectGesture()
: 敗北宣言時に呼ばれる
GestureRecognizer
Flutter公式の説明では、エントリ後の Arena は以下のような状態であると説明されている(Preudocode=疑似コード)。
ここでエントリしているの Recognizer は、下記のクラスから生成されたオブジェクトである。
TapGestureRecognizer
DoubleTapGestureRecognizer
LongPressGestureRecognizer
GestureRecognizer
を継承するクラスはとても多く、上記のクラスも含め、巨大なクラス群を形成している。
前述の通り Arena における勝敗は、参加者である Recognizer 自身が勝敗を宣言することによって決まる ため、そのアルゴリズムは各 GestureRecognizer
に委ねられている。
以下、動画の内容に沿って、各 GestureRecognizer
が保有しているアルゴリズムについて記載する。
TapGestureRecognizer
のアルゴリズム
勝利宣言をしない。
PointerDownEvent
と PointerUpEvent
の間に、ポインタの座標が離れた場合には敗北宣言する。これはユーザの操作がタップ以外に該当するためである。
DoubleTapGestureRecognizer
のアルゴリズム
PointerDownEvent
が発生すると 300ms のタイマーが設定され、タイマー内で2度目のタップが発生した場合に勝利宣言をする。
反対にタイマー内で2度目のタップが発生しない場合に敗北宣言する。
const Duration kDoubleTapTimeout = Duration(milliseconds: 300);
class DoubleTapGestureRecognizer extends GestureRecognizer {
Timer? _doubleTapTimer;
void _startDoubleTapTimer() {
_doubleTapTimer ??= Timer(kDoubleTapTimeout, _reset);
}
}
LongPressGestureRecognizer
のアルゴリズム
PointerDownEvent
が発生すると、500msのタイマーが設定され、その間ユーザによる長押しが継続された(PointerUpEvent
が発生しなかった)場合に勝利宣言する。
500ms内で PointerUpEvent
が発生すると敗北宣言をする。
const Duration kLongPressTimeout = Duration(milliseconds: 500);
class LongPressGestureRecognizer extends PrimaryPointerGestureRecognizer {
LongPressGestureRecognizer({
Duration? duration,
}) : super(
deadline: duration ?? kLongPressTimeout,
);
}
abstract class PrimaryPointerGestureRecognizer extends OneSequenceGestureRecognizer {
final Duration? deadline;
Timer? _timer;
@override
void addAllowedPointer(PointerDownEvent event) {
if (deadline != null) {
_timer = Timer(deadline!, () => didExceedDeadlineWithEvent(event));
}
}
}
Gesture Arena 例
YoutubeのFltter公式チャンネルにある GestureArena | Decoding Flutter で説明されている内容のまとめ。
PointerDownEvent
が発生した瞬間(0ms) Arena が開催し、3つの Recognizer がエントリした状態になっている。
PointerDownEvent
から 50ms 後に PointerUpEvent
が発生。
LongPressGestureRecognizer
は PointerDownEvent
の発生から500ms以内に PointerUpEvent
が発生すると敗北宣言を行うため、ここで退場する。
さらに、PointerDownEvent
の発生から500ms以内に2度目の PointerDownEvent
が発生しなかった場合、DoubleTapGestureRecognizer
が敗北宣言するため、 TapGestureRecognizer
が結果的に勝者となる。
【解析】
ここからはさらにFlutter(version 3.24.3)の内部の実装を解析する。
debugPrintGestureArenaDiagnostics
Flutter には debugPrintGestureArenaDiagnostics
というフラグによるデバッグ補助機能が用意されている。
bool debugPrintGestureArenaDiagnostics = false;
これを true
にすることで VSCode の DEBUG CONSOLE 上に Gesture 関連の詳細なログが出力されるようになる。
_GestureArena
一部を抜粋(ソースはこちら)
class _GestureArena {
final List<GestureArenaMember> members = <GestureArenaMember>[];
bool isOpen = true;
bool isHeld = false;
bool hasPendingSweep = false;
GestureArenaMember? eagerWinner;
void add(GestureArenaMember member) {
members.add(member);
}
}
GestureArenaManager
一部を抜粋(ソースはこちら)
class GestureArenaManager {
final Map<int, _GestureArena> _arenas = <int, _GestureArena>{};
GestureArenaEntry add(int pointer, GestureArenaMember member) {
final _GestureArena state = _arenas.putIfAbsent(pointer, () {
return _GestureArena();
});
state.add(member);
return GestureArenaEntry._(this, pointer, member);
}
void close(int pointer) {}
void sweep(int pointer) {}
void hold(int pointer) {}
void release(int pointer) {}
void _resolve(int pointer, GestureArenaMember member, GestureDisposition disposition) {}
void _tryToResolveArena(int pointer, _GestureArena state) {}
void _resolveByDefault(int pointer, _GestureArena state) {}
void _resolveInFavorOf(int pointer, _GestureArena state, GestureArenaMember member) {}
bool _debugLogDiagnostic(int pointer, String message, [ _GestureArena? state ]) {}
}
解決 の概念
Arena において、Gestre Recognizer の勝敗に決着がつくことは resolve (解決)と表現されている。
class GestureArenaManager {
void _resolve(int pointer, GestureArenaMember member, GestureDisposition disposition) {}
void _tryToResolveArena(int pointer, _GestureArena state) {}
void _resolveByDefault(int pointer, _GestureArena state) {}
void _resolveInFavorOf(int pointer, _GestureArena state, GestureArenaMember member) {}
}
解決の手段に関しては、後述する sweep()
が深く関わっている。
_GestureArene.add()
/ GestureArenaManager.add()
Arena への Recognizer のエントリは _GestureArena
の add()
を通じて行われる。
class GestureArenaManager {
final Map<int, _GestureArena> _arenas = <int, _GestureArena>{};
GestureArenaEntry add(int pointer, GestureArenaMember member) {
final _GestureArena state = _arenas.putIfAbsent(pointer, () {
return _GestureArena();
});
state.add(member);
return GestureArenaEntry._(this, pointer, member);
}
}
class _GestureArena {
final List<GestureArenaMember> members = <GestureArenaMember>[];
void add(GestureArenaMember member) {
members.add(member);
}
}
pointer
/ Gesture Arena の識別子
Arena 自体は PointerDownEvent
が持つ int
型の pointer
によって識別される。
そのため、Arena に対するメソッドは全て引数に Arena を識別するための pointer
を受け取る。
class PointerDownEvent extends PointerEvent
// PointerEvent の メンバ(pointer)を継承
}
abstract class PointerEvent {
// Arena の識別子
final int pointer;
}
class GestureArenaManager {
final Map<int, _GestureArena> _arenas = <int, _GestureArena>{};
// 引数の pointer によって Arena を識別
void close(int pointer) {}
void sweep(int pointer) {}
void hold(int pointer) {}
void release(int pointer) {}
}
この pointer
は Listener
から渡される PointerDownEvent
の pointer
である。
Listener
は RawGestureDetector
ウィジェットによってラップされていた。
_GestureArena.isOpen
/ GestureArenaManager.close()
isOpen
は、 Arena に対する Recognizer の 新たなエントリ が可能な状態かどうかを表す。
close()
によって isOpen = false
になっている場合、既に Arena は締め切られた状態になるため、新たな Recognizer をエントリさせることはできない。
class _GestureArena {
// 初期値が true
bool isOpen = true;
}
class _GestureArena {
final List<GestureArenaMember> members = <GestureArenaMember>[];
void add(GestureArenaMember member) {
assert(isOpen); // add() 時に open している必要がある
members.add(member);
}
}
close()
は Hit Test による PointerDownEvent
のウィジェットに対する紐づけ後に呼び出される。
class _GestureArena {
bool isOpen = true;
}
class GestureArenaManager {
final Map<int, _GestureArena> _arenas = <int, _GestureArena>{};
void close(int pointer) {
final _GestureArena? state = _arenas[pointer];
// close() によって false になる
state.isOpen = false;
}
}
mixin GestureBinding on BindingBase implements HitTestable, HitTestDispatcher, HitTestTarget {
@override // from HitTestTarget
void handleEvent(PointerEvent event, HitTestEntry entry) {
if (event is PointerDownEvent) {
gestureArena.close(event.pointer);
}
}
}
_GestureArena.isHeld
/ GestureArenaManager.hold()
isHeld
は Arena での解決が保留(hold)状態にあることを表す。
hold()
の実行によって保留された Arena は、sweep()
によって解決されない。
class GestureArenaManager {
final Map<int, _GestureArena> _arenas = <int, _GestureArena>{};
void sweep(int pointer) {
final _GestureArena? state = _arenas[pointer];
if (state.isHeld) {
// 保留中の Arena は sweep() が完遂されない
return; // This arena is being held for a long-lived member.
}
}
通常 PointerUpEvent
が発生すると「画面から指が離れた」ことによって、ジェスチャーの決定、すなわち Arena における 解決 が行われる。
しかし、例えば「2回分」の PointerUpEvent
を必要とする DoubleTapGestureRecognizer
は、1回目の PointerUpEvent
時に、解決の実行を待って欲しい。
この時 DoubleTapGestureRecognizer
によって hold()
が呼ばれ、解決の保留が実施される。
class DoubleTapGestureRecognizer extends GestureRecognizer {
void _registerFirstTap(_TapTracker tracker) {
GestureBinding.instance.gestureArena.hold(tracker.pointer);
}
}
また isHeld
は release()
によって false
になる。
class _GestureArena {
bool isHeld = false;
}
class GestureArenaManager {
final Map<int, _GestureArena> _arenas = <int, _GestureArena>{};
void hold(int pointer) {
final _GestureArena? state = _arenas[pointer];
// hold() によって true になる
state.isHeld = true;
}
void release(int pointer) {
final _GestureArena? state = _arenas[pointer];
// release() によって false になる
state.isHeld = false;
}
}
_GestureArena.isHeld
/ GestureArenaManager.release()
release()
は保留状態にされた Arena に対して解放を行い、解決のプロセスを再開させる。
更に release()
の内部では、勝者を決定するための sweep()
が呼び出される。
class _GestureArena {
bool isHeld = false;
}
class GestureArenaManager {
final Map<int, _GestureArena> _arenas = <int, _GestureArena>{};
void release(int pointer) {
final _GestureArena? state = _arenas[pointer];
// 保留状態が解除される
state.isHeld = false;
if (state.hasPendingSweep) {
// release() 内部から sweep() が呼び出される
sweep(pointer);
}
}
}
GestureArenaManager.sweep()
Arena の解決を強制実行する。最後に残った Recoognizer は勝者となり、それ以外は敗者になる。その結果、エントリ済みの Recognizer は一掃(sweep)される。
また release()
が呼ばれると内部で sweep()
が実行され、保留中のアリーナが解決される。
個々の Recognizer の解決には、acceptGesture()
もしくは rejectGesture()
が使用される。
class _GestureArena {
bool hasPendingSweep = false;
}
class GestureArenaManager {
final Map<int, _GestureArena> _arenas = <int, _GestureArena>{};
void release(int pointer) {
final _GestureArena? state = _arenas[pointer];
if (state.hasPendingSweep) {
sweep(pointer);
}
}
void sweep(int pointer) {
final _GestureArena? state = _arenas[pointer];
if (state.isHeld) {
state.hasPendingSweep = true;
return; // This arena is being held for a long-lived member.
}
_arenas.remove(pointer);
if (state.members.isNotEmpty) {
// First member wins.
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);
}
}
}
}
Arena における解決は、前述のルールも加味すると以下の2パターンがあると理解することができる。
いつでも勝利を宣言することができる。それにより、他の Recognizer は敗者になる。
① GestureRecognizer
自身が GestureRecognizer
クラス内部からで acceptGesture()
を呼ぶパターン。能動的に勝者が決まるパターンとも言える。この勝者は eager winner と呼ばれる。
いつでも敗北を宣言してアリーナを退場できる。最後に残った一つの Recognizer が勝者になる。
② GestureArenaManager
の sweep()
をきっかけにして GestureArenaManager
クラス内で acceptGesture()
が呼ばれるパターン。つまり GestureRecognizer
クラス外部から acceptGesture()
が呼ばれる。他のエントリが負けることにより、結果として残ったものが勝者となる受動的パターン。
_GestureArena.hasPendingSweep
Arena の sweep()
が完了していないことを表す。
hold()
によって保留された Arena に対して sweep()
が実行された場合、hasPendingSweep
フラグを立てて return
によって処理を途中で抜けるようになっている。
フラグはその後、 release()
の中で sweep()
を実行すべきかの確認のために使用される。
class _GestureArena {
bool hasPendingSweep = false;
}
class GestureArenaManager {
final Map<int, _GestureArena> _arenas = <int, _GestureArena>{};
void sweep(int pointer) {
final _GestureArena? state = _arenas[pointer];
if (state.isHeld) {
// sweep() が未遂フラグが立つ
state.hasPendingSweep = true;
return; // This arena is being held for a long-lived member.
}
}
void release(int pointer) {
final _GestureArena? state = _arenas[pointer];
// 保留状態が解除
state.isHeld = false;
if (state.hasPendingSweep) {
// 未遂の sweep() を完遂
sweep(pointer);
}
}
}
"eager winner"
eager=熱心な
周りが敗北宣言することによって勝ち残ったものではなく、積極的に自らが勝利宣言をすることによって勝利したものを eager winner として扱う。
_GestureArena
が保持している。
class _GestureArena {
GestureArenaMember? eagerWinner;
}
解決
ここまでの理解を踏まえて、更に解決の過程を深掘りする。
class GestureArenaManager {
void _resolve(int pointer, GestureArenaMember member, GestureDisposition disposition) {}
void _tryToResolveArena(int pointer, _GestureArena state) {}
void _resolveByDefault(int pointer, _GestureArena state) {}
void _resolveInFavorOf(int pointer, _GestureArena state, GestureArenaMember member) {}
}
処理フロー
_resolve()
の内部処理をフロー化すると以下のようになっていた。条件分岐によって大きく3つの処理フローが存在していることがわかる。
また 解決系のメソッド は _resolve()
内部で使用されていた。
-
勝利宣言が行われた場合
- Arena がオープンしている場合:"eagerWinner" の決定
- Arena がクローズされている場合:即座に
_resolveInFavorOf()
を呼び出して解決
-
敗北宣言が行われた場合
-
rejectGesture()
を呼び出し、Recognizer を Arena から削除 - Arena がクローズされている場合:
_tryToResolveArena()
を呼び出し、解決を図る
-
GestureArenaManager
の _resolve()
は GestureArenaEntry
から呼ばれる。
class GestureArenaEntry {
/// Call this member to claim victory (with accepted) or admit defeat (with rejected).
/// It's fine to attempt to resolve a gesture recognizer for an arena that is already resolved.
void resolve(GestureDisposition disposition) {
_arena._resolve(_pointer, _member, disposition);
}
}
ここから _resolve()
内部をさらに詳しくみていく。
処理フロー1
勝利宣言がされる中で eagerWinner が決定されるだけのシンプルな処理フロー。
処理フロー2
Arena へのエントリが締め切られた後に勝利宣言が行われるパターン。
_resolveInFavorOf()
が呼ばれている点が処理フロー1と異なる。
処理フロー3
敗北宣言が行われるパターン。
勝利宣言と同時に Arena の解決が実行される処理フロー1、2 とは異なり、勝ち残ったものが勝利者となる受動的な解決になるため、解決がやや消極的(try to resolve = 〜しようと試みる _tryToResolveArena()
)。
また _resolve()
内で GestureArenaMember.rejectGesture()
が呼び出されている。
_resolve()
void _resolve(
int pointer,
GestureArenaMember member,
GestureDisposition disposition,
) {
final _GestureArena? state = _arenas[pointer];
if (state == null) {
return; // This arena has already resolved.
}
switch (disposition) {
case GestureDisposition.accepted:
if (state.isOpen) {
state.eagerWinner ??= member;
} else {
_resolveInFavorOf(pointer, state, member);
}
case 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));
} else if (state.members.isEmpty) {
_arenas.remove(pointer);
} else if (state.eagerWinner != null) {
_resolveInFavorOf(pointer, state, state.eagerWinner!);
}
}
_tryToResolveArena()
内の処理は以下の3つに分けることができる。これらは Arena の状況に応じて使い分けられている。
if (state.members.length == 1) {
scheduleMicrotask(() => _resolveByDefault(pointer, state));
}
この場合、Arena に残る Recognizer を勝者として解決を行う。
現在進行中の処理に影響を与えないように、次のイベントループサイクルに _resolveByDefault()
の実行を後回ししている。
else if (state.members.isEmpty) {
_arenas.remove(pointer);
}
その pointer
における Arena を削除する。
else if (state.eagerWinner != null) {
_resolveInFavorOf(pointer, state, state.eagerWinner!);
}
"eagerWinner" を勝者として _resolveInFavorOf()
を呼び出す。
_resolveByDefault()
void _resolveByDefault(int pointer, _GestureArena state) {
if (!_arenas.containsKey(pointer)) {
return; // This arena has already resolved.
}
final List<GestureArenaMember> members = state.members;
assert(members.length == 1);
_arenas.remove(pointer);
state.members.first.acceptGesture(pointer);
}
assert
から Recognizer が1つの Arena に対して呼ばれることが前提であることがわかる。Arena が削除された後、acceptGesture()
が実行される。
_resolveInFavorOf()
in favor of = ~に有利な方向で、~を支持する
void _resolveInFavorOf(
int pointer,
_GestureArena state,
GestureArenaMember member,
) {
_arenas.remove(pointer);
for (final GestureArenaMember rejectedMember in state.members) {
if (rejectedMember != member) {
// 敗北宣言
rejectedMember.rejectGesture(pointer);
}
}
// 勝利宣言
member.acceptGesture(pointer);
}
引数に取る GestureArenaMember
を勝者として決定し、他のエントリを敗者として処理することで Arena を解決する。
Listener
Pointer Event は、Listener
ウィジェットを使用して監視することができる。
Listener(
onPointerDown: (PointerDownEvent event) {},
onPointerMove: (PointerMoveEvent event) {},
onPointerUp: (PointerUpEvent event) {},
onPointerCancel: (PointerCancelEvent event) {},
child: ...,
)
Listener
はジェスチャーとして認識される前の Pointer Event を監視することができるウィジェットで、 GestureDetector
よりも低レベルのイベントを扱うことができる。つまり GestureDetector
は Listener
をラップしたウィジェットである。
GestureDetector(
onTap: () {},
onTapDown: (TapDownDetails details) {},
onTapUp: (TapUpDetails details) {},
onTapCancel: () {},
)
Listener
は Pointer Event を監視できる。
GestureDetector
はジェスチャーを検知できる。
Pointer Event について調べようとした際に、GestureDetector
のコールバックに print()
などを仕込むと、生の Event を取得することはできない。未加工の情報を取得したければ、必ず Listener
を使用するようにする。
MouseRegion
MouseRegion
によってボタンが押されていないときでもマウスの動きを追跡することができる。
GestureDetector
GestureDetector
に設定できる onTapDown
は Gesture Arena で勝利する前に呼び出され、 onTapCancel
は敗北した時に呼び出される。
サイズ
子ウィジェットは指定しなくても良い。
子ウィジェットを指定した場合は子ウィジェットのサイズに制限され、指定しなかった場合は親ウィジェットのサイズを継承する。
GestureDetector(
onTap: () {},
child: Container(
width: 100,
height: 100,
),
)
Container(
width: 200,
height: 200,
child: GestureDetector(
onTap: () {},
),
)
GestureDetector
は
子ウィジェットを指定した場合は子ウィジェットのサイズに制限され、
指定しなかった場合は親ウィジェットのサイズを継承する。
Properties
onTap
関連
単押し操作に関連するもの。
引数 | 内容 |
---|---|
onTap |
シングルタップ |
onTapDown |
指が触れた瞬間 |
onTapUp |
指が離れた瞬間 |
onTapCancel |
タップ操作がキャンセルされたとき |
onDoubleTap |
ダブルタップ |
onLongPress
関連
長押し操作に関連するもの。
引数 | 内容 |
---|---|
onLongPress |
長押し |
onLongPressStart |
長押しが始まった瞬間 コールバックに LongPressStartDetails が引数として渡され、タッチ位置などの情報が取得可能 |
onLongPressMoveUpdate |
長押し中に指が動いたとき コールバックに LongPressMoveUpdateDetails が引数として渡される |
onLongPressUp |
長押しをやめた(指を画面から離した)とき |
onLongPressEnd |
長押しが終了したとき(指を離したりキャンセルされたりしたとき) コールバックに LongPressEndDetails が引数として渡される |
onPan
関連
ドラッグに関連するもの。
引数 | 内容 |
---|---|
onPanStart |
ドラッグを開始したとき コールバックに DragStartDetails が引数として渡され、タッチ開始位置が取得可能 |
onPanUpdate |
ドラッグ中に指が動いたとき コールバックに DragUpdateDetails が引数として渡され、移動距離や現在位置が取得可能 |
onPanEnd |
ドラッグが終了したとき コールバックに DragEndDetails が引数として渡され、速度や終了状態が取得可能 |
onPanCancel |
ドラッグ操作がキャンセルされたとき |
onHorizontalDragDown |
水平方向のドラッグ操作を開始したときの指が画面に触れたとき コールバックに DragDownDetails が引数として渡される |
onHorizontalDragStart |
水平方向のドラッグ操作が開始されたとき |
onHorizontalDragUpdate |
水平方向のドラッグ操作中に、位置が更新されたとき コールバックに DragUpdateDetails が引数として渡される |
onHorizontalDragEnd |
水平方向のドラッグ操作が終了したとき コールバックに DragEndDetails が引数として渡される |
onHorizontalDragCancel |
水平方向のドラッグ操作が途中でキャンセルされたとき |
onVerticalDragDown |
垂直方向のドラッグ操作を開始したときの指が画面に触れたとき コールバックに DragDownDetails が引数として渡される |
onVerticalDragStart |
垂直方向のドラッグ操作が開始されたとき |
onVerticalDragUpdate |
垂直方向のドラッグ操作中に、位置が更新されたとき コールバックに DragUpdateDetails が引数として渡される |
onVerticalDragEnd |
垂直方向のドラッグ操作が終了したとき コールバックに DragEndDetails が引数として渡される |
onVerticalDragCancel |
垂直方向のドラッグ操作が途中でキャンセルされたとき |
dragStartBehavior
onScale
関連
ピンチインやピンチアウトなどのスケールに関連するもの。
引数 | 内容 |
---|---|
onScaleStart |
スケール操作を開始したとき |
onScaleUpdate |
スケール操作中 コールバックに ScaleUpdateDetails が引数として渡され、拡大率や回転角度の変化が取得可能 |
onScaleEnd |
スケール操作が終了したとき コールバックに ScaleEndDetails が引数として渡され、最終的な状態や速度が取得可能 |
onSecondaryTap
関連
右クリックや二本指タップなどのセカンダリタップに関わるもの。
引数 | 内容 |
---|---|
onSecondaryTap |
セカンダリタップ |
onSecondaryTapDown |
指が画面に触れた瞬間 コールバックに TapDownDetails が引数として渡され、位置情報が取得可能 |
onSecondaryTapUp |
指を離したとき コールバックに TapUpDetails が引数として渡され、位置情報が取得可能 |
onSecondaryTapCancel |
セカンダリタップがキャンセルされたとき |
onTertiaryTap
関連
三本指タップなどのサードタップに関わるもの。
引数 | 内容 |
---|---|
onTertiaryTapDown |
指が画面に触れた瞬間 コールバックに TapDownDetails が引数として渡され、位置情報が取得可能 |
onTertiaryTapUp |
指を離したとき コールバックに TapUpDetails が引数として渡され、位置情報が取得可能 |
onTertiaryTapCancel |
サードタップがキャンセルされたとき |
onDoubleTap
関連
ダブルタップに関わるもの。
引数 | 内容 |
---|---|
onDoubleTap |
ダブルタップ |
onDoubleTapDown |
指が画面に触れた瞬間 コールバックに TapDownDetails が引数として渡される |
onDoubleTapCancel |
ダブルタップがキャンセルされたとき |
onForcePress
関連
圧力感知に関わるもの(iOS専用)。
引数 | 内容 |
---|---|
onForcePressStart |
圧力検知(ForcePress)が開始されたとき |
onForcePressPeak |
圧力がピークに達したとき コールバックに ForcePressDetails が引数として渡される |
onForcePressUpdate |
圧力が更新されたとき コールバックに ForcePressDetails が引数として渡され、圧力レベルの変化を監視できる |
onForcePressEnd |
圧力検知(ForcePress)が終了したとき コールバックに ForcePressDetails が引数として渡される |
behavior
関連
引数 | 内容 |
---|---|
behavior |
GestureDetector が Hit Test によって検出される際の挙動の指定- HitTestBehavior.deferToChild - HitTestBehavior.opaque - HitTestBehavior.translucent
|
dragStartBehavior |
ドラッグ操作が検出されるタイミング - DragStartBehavior.start (デフォルト) - DragStartBehavior.down
|
behavior
Hit Test の挙動を制御するプロパティ。
GestureDetector
は Hit Test の結果を利用してジェスチャーを処理している。
詳細はFlutter公式の Troubleshooting に説明があるものの、理解するまでに非常に時間のかかった。(理解できたのかすら怪しいが、なんとかまとめてみた)
HitTestBehavior
enum HitTestBehavior {
deferToChild,
opaque,
translucent,
}
デフォルト値
This defaults to HitTestBehavior.deferToChild
if child is not null and HitTestBehavior.translucent
if child is null.
つまり、
child != null
---> deferToChild
child == null
---> translucent
ということ。実装 を確認してみても、その通りになっていた。
HitTestBehavior get _defaultBehavior {
return widget.child == null ? HitTestBehavior.translucent : HitTestBehavior.deferToChild;
}
deferToChild
Targets that defer to their children receive events within their bounds only if one of their children is hit by the hit test.
GestureDetector
自身は Hit Test の対象とされない(自身の領域に対する Hit Test 被検出を放棄する)。ただし被検出自体を放棄するわけではなく、Hit Test は child
に指定したウィジェットへ委譲される。
結果的に child
が Hit Test によって検出された場合にのみ、 GestureDetector
も Hit Test によって検出されるという挙動をとる。
この挙動を検証してみた。
使用するサンプルはこちら。
class MyGestureDetector extends StatelessWidget {
const MyGestureDetector({super.key});
@override
Widget build(BuildContext context) {
return Container(
color: Colors.red,
height: 400,
width: 400,
);
}
}
以下のような赤い正方形を使用する。
まずこの赤い四角形を GestureDetector
でラップし、GestureDetector
の Hit Test による被検出を、この赤い Container
に委譲する設定を行う。
behavior
はデフォルトで既に deferToChild
になっているはずだが、あえて明示的に指定を行う。
class MyGestureDetector extends StatelessWidget {
const MyGestureDetector({super.key});
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.deferToChild, // Hit Test を child に委譲
onTapDown: (_) => debugPrint('tap down'),
child: Container( // Hit Testが委譲された child
color: Colors.red,
height: 400,
width: 400,
),
);
}
}
すると、Container
が Hit Test で検出された時だけ GestureDetector
の onTapDown
も連動して反応した。
次に、以下のような構成に変更して、存在しない child
へ Hit Test を委譲する形にしてみた。
class MyGestureDetector extends StatelessWidget {
const MyGestureDetector({super.key});
@override
Widget build(BuildContext context) {
return Container(
color: Colors.red,
height: 400,
width: 400,
child: GestureDetector(
behavior: HitTestBehavior.deferToChild,
onTapDown: (_) => debugPrint('tap down'),
// Hit Test が委譲される child が存在しない
),
);
}
}
すると、GestureDetector
の onTapDown
が反応しなくなった。Hit Test によって検出されるウィジェットが存在しなくなったためと考えられる。
child
を持たない GestureDetector
が描画されていないのでは?という疑問が湧いたため、一応確認をしてみたところ、GestureDetector
ウィジェット自体は、下記画像のように、確かに画面上に 400 x 400 px
の領域を取って存在していた。
opaque
Opaque targets can be hit by hit tests, causing them to both receive events within their bounds and prevent targets visually behind them from also receiving events.
GestureDetector
が Padding
の余白部分などの透明な領域を含んでいても、その領域を不透明扱い(opaque)とすることにより Hit Test で検出できるようにする。
Flutter において、Padding
の余白に代表される「何も描画されない状態、領域」は、「透明」と表現されることがある(特に Flutter公式サイト)。
この文脈における「透明」とは、単に Colors.transparent
や Color.fromARGB(0, 0, 0, 0)
などのカラーを指すものではない。そのため、挙動の違いを検証するために、これらの色を指定しても、「透明」の領域を作ったことにならない場合があるため注意。
opaque
を使用した場合の効果を検証してみた。
使用するサンプルはこちら。
class MyGestureDetector extends StatelessWidget {
const MyGestureDetector({super.key});
@override
Widget build(BuildContext context) {
return Stack(
alignment: AlignmentDirectional.center,
children: [
Container(
color: Colors.blue,
height: 400,
width: 400,
),
Padding(
padding: const EdgeInsets.all(50),
child: Container(
color: Colors.red,
height: 300,
width: 300,
),
),
],
);
}
}
青い正方形(400 x 400 px
)の上に赤い正方形(300 x 300 px
)を重ねている。赤い正方形は上下左右に余白をそれぞれ 50 px
ずつを持っていて、余白領域を合わせると青い正方形と同じ領域を占めていることになる。
まず deferToChild
を明示的に指定して、赤い領域やその余白領域の反応を確認してみる(deferToChild
は デフォルト値)。
class MyGestureDetector extends StatelessWidget {
const MyGestureDetector({super.key});
@override
Widget build(BuildContext context) {
return Stack(
alignment: AlignmentDirectional.center,
children: [
Container(
color: Colors.blue,
height: 400,
width: 400,
),
GestureDetector(
behavior: HitTestBehavior.deferToChild, // デフォルトの挙動を確認
onTapDown: (_) => debugPrint('tap down'),
child: Padding(
padding: const EdgeInsets.all(50),
child: Container(
color: Colors.red,
height: 300,
width: 300,
),
),
),
],
);
}
}
Padding
の余白領域の onTapDown
は反応していなかった。
ここで deferToChild
を opaque
に変更してみる。
class MyGestureDetector extends StatelessWidget {
const MyGestureDetector({super.key});
@override
Widget build(BuildContext context) {
return Stack(
alignment: AlignmentDirectional.center,
children: [
Container(
color: Colors.blue,
height: 400,
width: 400,
),
GestureDetector(
behavior: HitTestBehavior.opaque, // opaque の時どうなる?
onTapDown: (_) => debugPrint('tap down'),
child: Padding(
padding: const EdgeInsets.all(50),
child: Container(
color: Colors.red,
height: 300,
width: 300,
),
),
),
],
);
}
}
すると、Padding
の余白領域がタップへ反応するようになった。
opaque
を指定したことで「透明」の余白領域を持つ GestureDetector
全体が「不透明」扱いになったためと考えられる。
またopaque
には、視覚的に背後に配置されたウィジェットの Hit Test による検出を妨げる効果がある。
これについても検証してみる。
今回は、赤い GestureDetector
の「余白」が背後の青い GestureDetector
の Hit Test を妨げるかどうかだけを見たいので、先程のサンプルの青い領域にのみ onTapDown
を設定しておく。
まずはデフォルト値の deferToChild
から。特に余白領域のタップに注目する。
class MyGestureDetector extends StatelessWidget {
const MyGestureDetector({super.key});
@override
Widget build(BuildContext context) {
return Stack(
alignment: AlignmentDirectional.center,
children: [
GestureDetector(
// 背後にのみ設定しておく
onTapDown: (_) => debugPrint('[background] tap down'),
child: Container(
color: Colors.blue,
height: 400,
width: 400,
),
),
GestureDetector(
behavior: HitTestBehavior.deferToChild, // デフォルト値
child: Padding(
padding: const EdgeInsets.all(50),
child: Container(
color: Colors.red,
height: 300,
width: 300,
),
),
),
],
);
}
}
赤い GestureDetector
の余白領域は、背後の青い GestureDetector
の検出を妨げなかった。
deferToChild
を opaque
に変更してみる。
class MyGestureDetector extends StatelessWidget {
const MyGestureDetector({super.key});
@override
Widget build(BuildContext context) {
return Stack(
alignment: AlignmentDirectional.center,
children: [
GestureDetector(
onTapDown: (_) => debugPrint('[background] tap down'),
child: Container(
color: Colors.blue,
height: 400,
width: 400,
),
),
GestureDetector(
behavior: HitTestBehavior.opaque, // opaque にしてみる
child: Padding(
padding: const EdgeInsets.all(50),
child: Container(
color: Colors.red,
height: 300,
width: 300,
),
),
),
],
);
}
}
Padding
の余白領域に対するタップには全く反応しなくなった。
前面の赤い GestureDetector
(の余白領域)によって、後方の GestureDetector
の Hit Test による検出が妨げられたためと考えられる。
translucent
Translucent targets both receive events within their bounds and permit targets visually behind them to also receive events.
opaque
同様に、透明な余白領域を含む自身を Hit Test 対象に追加するが、opaque
(不透明) とは異なり半透明(translucent)であるため、視覚上、背後に位置するウィジェットが Hit Test によって検出されることを阻まない。
特に oqaque
との挙動の違いを検証してみた。
使用するサンプルはこちら(opaque
の時と同じ)。
class MyGestureDetector extends StatelessWidget {
const MyGestureDetector({super.key});
@override
Widget build(BuildContext context) {
return Stack(
alignment: AlignmentDirectional.center,
children: [
Container(
color: Colors.blue,
height: 400,
width: 400,
),
Padding(
padding: const EdgeInsets.all(50),
child: Container(
color: Colors.red,
height: 300,
width: 300,
),
),
],
);
}
}
青い正方形(400 x 400 px
)の上に赤い正方形(300 x 300 px
)を重ねている。赤い正方形は上下左右に余白をそれぞれ 50 px
を持っていて、余白領域を合わせると青い正方形と同じ領域を占めていることになる。
opaque
で検証した時の挙動をおさらいする。
赤い GestureDetector
の余白領域の背後の青い GestureDetector
は、 前方の赤い GestureDetector
に設定した opaque
によって Hit Test で検出されなくなるため、タップに反応しない。
class MyGestureDetector extends StatelessWidget {
const MyGestureDetector({super.key});
@override
Widget build(BuildContext context) {
return Stack(
alignment: AlignmentDirectional.center,
children: [
// 背後のウィジェット
GestureDetector(
onTapDown: (_) => debugPrint('[background] tap down'),
child: Container(
color: Colors.blue,
height: 400,
width: 400,
),
),
// 前方のウィジェット
GestureDetector(
behavior: HitTestBehavior.opaque, // opaque
child: Padding(
padding: const EdgeInsets.all(50),
child: Container(
color: Colors.red,
height: 300,
width: 300,
),
),
),
],
);
}
}
これを translucent
に変更してみる。
class MyGestureDetector extends StatelessWidget {
const MyGestureDetector({super.key});
@override
Widget build(BuildContext context) {
return Stack(
alignment: AlignmentDirectional.center,
children: [
// 背後のウィジェット
GestureDetector(
onTapDown: (_) => debugPrint('[background] tap down'),
child: Container(
color: Colors.blue,
height: 400,
width: 400,
),
),
// 前方のウィジェット
GestureDetector(
behavior: HitTestBehavior.translucent, // translucent に変更
child: Padding(
padding: const EdgeInsets.all(50),
child: Container(
color: Colors.red,
height: 300,
width: 300,
),
),
),
],
);
}
}
すると余白領域をタップした際に、背後の青い GestureDetector
が反応するようになった。
赤い GestureDetector
の特に余白領域が、不透明(opaque)から半透明(translucent)扱いになったことで、背後の青い領域が Hit Test によって検出されるようになったためと考えられる。
その他
引数 | 内容 |
---|---|
excludeFromSemantics |
セマンティクス(アクセシビリティツールでの操作情報)からこのウィジェットを除外するかどうかの指定 - true - false (デフォルト) |
supportedDevices |
GestureDetector が反応するデバイスを制限するためのセット |
trackpadScrollCausesScale |
トラックパッドのスクロールジェスチャーをスケール操作として扱うかどうか - true - false (デフォルト) |
trackpadScrollToScaleFactor |
トラックパッドスクロールによるスケール操作の倍率 (デフォルト値) kDefaultTrackpadScrollToScaleFactor = Offset(0, -1/200)
|