1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Flutter Gesture

Last updated at Posted at 2025-01-15

※記事内のコードは Flutter 3.24.3 より抜粋

【概要】

ジェスチャー検出の仕組み

Futter では、ジェスチャーは以下の順番で処理されている。

  1. Pointer Event の取得
  2. Hit Test
  3. ジェスチャーの認識
  4. コールバックの実行

Pointer Event の取得

Flutterは、画面上でユーザが行ったタップ、ドラッグ、スワイプなどの操作を Pointer Event として収集している。

Pointer Event には下のような種類がある。

  • PointerDown
  • PointerMove
  • PointerUp
  • PointerCancel

この時点におけるユーザ操作は、単なるイベントであり、まだジェスチャーとしては認識されていない。

「指が触れた」「指が離れた」などの画面上の操作は、Flutterで Pointer Event として発生する

pointer_event.png

https://youtu.be/zEoASR7DTIw?si=jZWgkMEoNaJlpKHX

Hit Test

画面上の座標に基づき、発生した Pointer Event をどのウィジェットが処理するかを決定する。

これにより Pointer Event が特定のウィジェットと紐付けられる。

この過程は Hit Test と呼ばれ、 Render Tree と呼ばれるツリー構造上で「親から子ウィジェットの方向」へ順次実行される。

Pointer Event は Hit Test によって特定のウィジェットに紐づけられる

Screenshot 2024-11-24 at 17.02.15.png

https://youtu.be/zEoASR7DTIw?si=jZWgkMEoNaJlpKHX

ジェスチャーの認識

Flutter 上で発生した「指が画面に触れた」「指が画面から離れた」などの Pointer Event は、単なるイベントであり、これをアプリケーション上の操作、すなわち ジェスチャー として認識する必要がある。

例えば、PointerDown → PointerMove → PointerUp などの一連の複数の Pointer Event は、スワイプとして認識されることで、イベントからジェスチャーに昇格する。

Flutterには既に定義されたジェスチャーが複数存在する。

Gestures.png

引用元:https://www.kodeco.com/29002200-creating-custom-gestures-in-flutter

ユーザ操作が特定のジェスチャーとして認識されることで、それぞれのジェスチャーに対して異なるコールバックを設定することができる。

ジェスチャーの競合

複数のイベントが PointerDownPointerUp の順に発生した際、一般的にこれは「タップ」として認識される。

一方で PointerDownPointerUp が短時間の間に2回連続した場合「ダブルタップ」として認識する必要がある。

この時、発生する一連の Pointer Event は、単一のジェスチャー(操作)として認識される必要がある。

ここで登場するのがジェスチャー同士の競合という概念である。ジェスチャー は アリーナ と呼ばれる闘技場(arena=闘技場)においてそれぞれが競い合い、勝者と決定する。

このアリーナは単なる比喩ではなく、実際に _GestureArena というクラス名で実装されている。

アリーナでは参加者による エントリー が行われる。

エントリー(参加)するのは Gesture Recognizer である。

アリーナにおける「勝利」はFlutter上で「特定のジェスチャーとして認識される」ことを意味する。反対に「敗北」は一連の Pointer Event がジェスチャーとして扱われないことを意味する。

Gesture Recognizer には

などの種類がある。

コールバックの実行

画面上のユーザ操作が特定のジェスチャーとして認識されると、GestureDetector でそれぞれのジェスチャーに対して紐づけられたコールバックが実行される。

GestureDetector
GestureDetector(
  onTap:  () {},
  onTapDown: (TapDownDetails details) {},
  onTapUp: (TapUpDetails details) {},
  onTapCancel: () {},
)

Screenshot 2024-11-27 at 10.13.28.png

https://docs.flutter.dev/ui/interactivity/gestures

【詳細】

Pointers & Events

Flutterでは、ユーザーが画面上で行うタッチやマウスの操作を Pointer として扱う。

そして、これらの操作が内部で処理される際は Pointer Event が発生する。

実装では PointerEvent クラスとして表現されている。

PointerEvent

PointerEvent クラスはabstractクラスで、下記のクラス群が拡張(extends)している。

PointerEvent
abstract class PointerEvent {}
PointerAddedEvent
// デバイスが Pointer の追跡を開始した瞬間に発生
// 指がまだ画面に触れていない可能性もある
class PointerAddedEvent extends PointerEvent {}
PointerDownEvent
// 指が画面に触れた瞬間に発生
// タッチの開始を意味する
class PointerDownEvent extends PointerEvent {}
PointerMoveEvent
// 指が画面上を移動している間、連続的に発生
// 移動の軌跡が追跡できる
class PointerMoveEvent extends PointerEvent {}
PointerUpEvent
// 指が画面から離れた瞬間に発生
// タッチの終了を意味する
class PointerUpEvent extends PointerEvent {}
PointerCancelEvent
// タッチイベントが中断された場合に発生
// 例: 電話の着信や他のアプリがフォーカスを奪ったとき
class PointerCancelEvent extends PointerEvent {}
PointerHover
// マウスカーソルがウィジェットの上に乗ったとき
// タッチデバイス(`PointerDeviceKind.touch`)では発生しない
class PointerHover extends PointerEvent {}
PointerSignal
// マウスホイールの回転によるスクロール操作などのポインター(マウスやタッチデバイスなど)から発生する離散的な信号を表すイベント
// ポインター自体の状態(位置やボタンの押下状態など)を変化させることなく発生し、連続的なイベントの文脈で解釈する必要がない単発のイベント
class PointerSignal extends PointerEvent {}

各イベントは連続的に発生するシーケンスである。

pointer_event.png

https://youtu.be/zEoASR7DTIw?si=jZWgkMEoNaJlpKHX

また PointerEvent は、 ID(識別子)、座標、デバイスなどの属性を持つ。

pointer(ID)

PointerEvent を区別するための識別子。

PointerEvent
abstract class PointerEvent {
  // 発生した PointerEvent が、どのポインタに紐づいているか
  // 2本指の操作では、それぞれのポインタを別々のIDで追跡する
  // または、一連の PointerDown, PointerMove, PointerUp イベントを、同じIDで関連付ける
  int pointer = 0;
}

座標

操作が画面上のどの位置で発生したかを表す座標。

PointerEvent
abstract class PointerEvent {
  // 現在のイベントが発生したグローバル座標(スクリーン全体での位置)
  Offset position = Offset.zero;

  // 前回の PointerEvent からの相対的な位置の変化
  // 操作開始座標を起点としてどの方向に Pointer が移動したかを判断する
  Offset delta = Offset.zero;

  // 座標系を変換するための行列
  // スクリーン座標変換とローカル座標変換に使用される
  Matrix4? transform;
}

デバイス

PointerEvent
abstract class PointerEvent {
  // デバイスの種類
  PointerDeviceKind kind = PointerDeviceKind.touch;

  // デバイスを識別するID
  int device = 0;
}

enum の PointerDeviceKind で表現される。

PointerDeviceKind
enum PointerDeviceKind {
  // タッチ(指で画面に触れる操作)
  touch,

  // マウス(クリックやドラッグ操作)
  mouse,

  // スタイラス(タッチペン操作用のデバイス)
  stylus,

  // スタイラスの逆側(消しゴム機能)
  invertedStylus,

  // トラックパッド
  trackpad,
  
  // 不明
  unknown,
}

Hit Test

ユーザーの操作をどのウィジェットが処理するべきかを決定するプロセス。

Render TreeRenderObject のツリー)上で行われ、Pointer Event を適切なウィジェットに紐付ける。

具体的には、画面上でユーザによる Pointer の操作が行われると、Flutter は PointerEvent オブジェクトを生成する。この PointerEvent に座標情報(position)が含まれている。

PointerEvent
abstract class PointerEvent {
  Offset position = Offset.zero;
}

Hit Test は親から子の方向へ向かって実行されるため、まず初めは、ルートに位置する RenderObject が対象となる。

Screenshot 2024-11-24 at 17.00.56.png

https://youtu.be/zEoASR7DTIw?si=jZWgkMEoNaJlpKHX

RenderObjectabstract クラスの HitTestable で定義された hitTest() を拡張(extends)している。

Hit Test 時にはこの hitTest() が呼ばれる。

HitTestable
abstract class HitTestable {
  void hitTest(HitTestResult result, Offset position);
}
MyRenderObject
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 だけがリストとして保持される。

HitTestTarget
abstract class HitTestTarget {
  void handleEvent(PointerEvent event, HitTestEntry entry);
}
HitTestEntry
class HitTestEntry {
  HitTestEntry(this.target);

  final HitTestTarget target;
}
HitTestResult
class HitTestResult {
  Iterable<HitTestEntry> get path => _path;
  final List<HitTestEntry> _path;

  void add(HitTestEntry entry) {
    _path.add(entry);
  }
}

Screenshot 2024-11-24 at 17.01.21.png

Screenshot 2024-11-24 at 17.01.31.png

Screenshot 2024-11-24 at 17.01.41.png

Screenshot 2024-11-24 at 17.02.15.png

https://youtu.be/zEoASR7DTIw?si=jZWgkMEoNaJlpKHX

Hit Test が一通り完了すると、HitTestResult に格納された HitTestEntry では、Pointer Event が順次処理される。

具体的には、各 HitTestEntry に関連付けられた HitTestTargethandleEvent() が呼び出される。

ここで重要なのが、リスト内の HitTestEntry はリストに追加された順番とは逆順で呼び出されることである。つまり、子ウィジェットから親ウィジェットへと遡る形で handleEvent() が実行される。

Hit Test は親から子に向かって実行され、 handleEvent() は子から親に向かって実行される。

HitTestTarget
abstract class HitTestTarget {
  void handleEvent(PointerEvent event, HitTestEntry entry);
}
MyRenderObject
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 の伝播に重要な影響を与える。

ここの挙動を制御するには、GestureDetectorbehavior を設定する必要がある。

Gesture Arena

ジェスチャーの競技場(アリーナ)

FLutter公式サイトでは Gesture disambiguation (ジェスチャーの曖昧さ回避)というセクションで説明されている。

また Gesture Arena は、YouTubeの公式Flutterチャンネルの Decoding Flutter というシリーズの一つとしても取り上げられている。

Flutterは、流れていく Pointer Event のストリームのすべてを監視(listen)していて、TapGestureRecognizerLongPressGestureRecognizerPanGestureRecognizer などの特定のジェスチャとして認識しようとする。

Arena はユーザ操作が複数のジェスチャー間で競合する際に、どのジェスチャーとしてハンドリングするかを決定する仕組みである。

gesture_arena.png

引用元:https://youtu.be/Q85LBtBdi0U?si=WccdFgoGFDENAUli

エントリ

Arena にエントリするのは GestureArenaMember である。

GestureArenaMember
abstract class GestureArenaMember {}

ただし、GestureArenaMember の実体は GestureRecognizer

GestureRecognizer
abstract class GestureRecognizer extends GestureArenaMember {}

エントリは _GestureAreneadd() によって行われる。

_GestureArene
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つのメソッドにより実現される。

GestureArenaMember
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=疑似コード)。

Screenshot 2024-11-24 at 16.35.00.png

引用元:https://youtu.be/Q85LBtBdi0U?si=WccdFgoGFDENAUli

ここでエントリしているの Recognizer は、下記のクラスから生成されたオブジェクトである。

  • TapGestureRecognizer
  • DoubleTapGestureRecognizer
  • LongPressGestureRecognizer

GestureRecognizer を継承するクラスはとても多く、上記のクラスも含め、巨大なクラス群を形成している。

GestureRecognizer.png

前述の通り Arena における勝敗は、参加者である Recognizer 自身が勝敗を宣言することによって決まる ため、そのアルゴリズムは各 GestureRecognizer に委ねられている。

以下、動画の内容に沿って、各 GestureRecognizer が保有しているアルゴリズムについて記載する。

TapGestureRecognizer のアルゴリズム

GestureRecognizer.png

勝利宣言をしない

PointerDownEventPointerUpEvent の間に、ポインタの座標が離れた場合には敗北宣言する。これはユーザの操作がタップ以外に該当するためである。

DoubleTapGestureRecognizer のアルゴリズム

GestureRecognizer.png

PointerDownEvent が発生すると 300ms のタイマーが設定され、タイマー内で2度目のタップが発生した場合に勝利宣言をする。

反対にタイマー内で2度目のタップが発生しない場合に敗北宣言する。

DoubleTapGestureRecognizer
const Duration kDoubleTapTimeout = Duration(milliseconds: 300);

class DoubleTapGestureRecognizer extends GestureRecognizer {
  Timer? _doubleTapTimer;

  void _startDoubleTapTimer() {
    _doubleTapTimer ??= Timer(kDoubleTapTimeout, _reset);
  }
}

LongPressGestureRecognizer のアルゴリズム

GestureRecognizer.png

PointerDownEvent が発生すると、500msのタイマーが設定され、その間ユーザによる長押しが継続された(PointerUpEvent が発生しなかった)場合に勝利宣言する。

500ms内で PointerUpEvent が発生すると敗北宣言をする。

LongPressGesuterRecognizer
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 がエントリした状態になっている。

gesture_2.png

引用元:https://youtu.be/Q85LBtBdi0U?si=WccdFgoGFDENAUli

PointerDownEvent から 50ms 後に PointerUpEvent が発生。

LongPressGestureRecognizerPointerDownEvent の発生から500ms以内に PointerUpEvent が発生すると敗北宣言を行うため、ここで退場する。

gesture_3.png

引用元:https://youtu.be/Q85LBtBdi0U?si=WccdFgoGFDENAUli

さらに、PointerDownEvent の発生から500ms以内に2度目の PointerDownEvent が発生しなかった場合、DoubleTapGestureRecognizer が敗北宣言するため、 TapGestureRecognizer が結果的に勝者となる。

gesture_4.png

引用元:https://youtu.be/Q85LBtBdi0U?si=WccdFgoGFDENAUli

【解析】

ここからはさらにFlutter(version 3.24.3)の内部の実装を解析する。

debugPrintGestureArenaDiagnostics

Flutter には debugPrintGestureArenaDiagnostics というフラグによるデバッグ補助機能が用意されている。

debugPrintGestureArenaDiagnostics
bool debugPrintGestureArenaDiagnostics = false;

これを true にすることで VSCode の DEBUG CONSOLE 上に Gesture 関連の詳細なログが出力されるようになる。

Screenshot 2025-01-07 at 21.10.29.png

_GestureArena

一部を抜粋(ソースはこちら

_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

一部を抜粋(ソースはこちら

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 のエントリは _GestureArenaadd() を通じて行われる。

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);
  }
}

close.png

pointer / Gesture Arena の識別子

Arena 自体は PointerDownEvent が持つ int 型の pointer によって識別される。

そのため、Arena に対するメソッドは全て引数に Arena を識別するための pointer を受け取る。

GestureArenaManager
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) {}
}

この pointerListener から渡される PointerDownEventpointer である。

ListenerRawGestureDetector ウィジェットによってラップされていた。

add.png

_GestureArena.isOpen / GestureArenaManager.close()

isOpen は、 Arena に対する Recognizer の 新たなエントリ が可能な状態かどうかを表す。

close() によって isOpen = false になっている場合、既に Arena は締め切られた状態になるため、新たな Recognizer をエントリさせることはできない。

isOpen
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 のウィジェットに対する紐づけ後に呼び出される。

close()
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);
    }
  }
}

close.png

_GestureArena.isHeld / GestureArenaManager.hold()

isHeld は Arena での解決が保留(hold)状態にあることを表す。

hold() の実行によって保留された Arena は、sweep() によって解決されない。

保留中の GestureArena は解決されない
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() が呼ばれ、解決の保留が実施される。

DoubleTapGestureRecognizer が hold() を要求している
class DoubleTapGestureRecognizer extends GestureRecognizer {
  void _registerFirstTap(_TapTracker tracker) {
    GestureBinding.instance.gestureArena.hold(tracker.pointer);
  }
}

また isHeldrelease() によって false になる。

isHeld / hold()
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;
  }
}

hold.png

_GestureArena.isHeld / GestureArenaManager.release()

release() は保留状態にされた Arena に対して解放を行い、解決のプロセスを再開させる。

更に release() の内部では、勝者を決定するための sweep() が呼び出される。

isHeld / release()
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);
    }
  }
}

release.png

GestureArenaManager.sweep()

Arena の解決を強制実行する。最後に残った Recoognizer は勝者となり、それ以外は敗者になる。その結果、エントリ済みの Recognizer は一掃(sweep)される。

また release() が呼ばれると内部で sweep() が実行され、保留中のアリーナが解決される。

個々の Recognizer の解決には、acceptGesture() もしくは rejectGesture() が使用される。

hasPendingSweep / sweep()
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);
      }
    }
  }
}

sweep.png

Arena における解決は、前述のルールも加味すると以下の2パターンがあると理解することができる。

いつでも勝利を宣言することができる。それにより、他の Recognizer は敗者になる。

GestureRecognizer 自身が GestureRecognizer クラス内部からで acceptGesture() を呼ぶパターン。能動的に勝者が決まるパターンとも言える。この勝者は eager winner と呼ばれる。

いつでも敗北を宣言してアリーナを退場できる。最後に残った一つの Recognizer が勝者になる。

GestureArenaManagersweep() をきっかけにして GestureArenaManager クラス内で acceptGesture() が呼ばれるパターン。つまり GestureRecognizer クラス外部から acceptGesture() が呼ばれる。他のエントリが負けることにより、結果として残ったものが勝者となる受動的パターン。

_GestureArena.hasPendingSweep

Arena の sweep() が完了していないことを表す。

hold() によって保留された Arena に対して sweep() が実行された場合、hasPendingSweep フラグを立てて return によって処理を途中で抜けるようになっている。

フラグはその後、 release() の中で sweep() を実行すべきかの確認のために使用される。

hasPendingSweep
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 が保持している。

eagerWinner
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() を呼び出し、解決を図る

_resolve.png

GestureArenaManager_resolve()GestureArenaEntry から呼ばれる。

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

_resolve_2.png

勝利宣言がされる中で eagerWinner が決定されるだけのシンプルな処理フロー。

処理フロー2

_resolve_2.png

Arena へのエントリが締め切られた後に勝利宣言が行われるパターン。

_resolveInFavorOf() が呼ばれている点が処理フロー1と異なる。

処理フロー3

敗北宣言が行われるパターン。

勝利宣言と同時に Arena の解決が実行される処理フロー1、2 とは異なり、勝ち残ったものが勝利者となる受動的な解決になるため、解決がやや消極的(try to resolve = 〜しようと試みる _tryToResolveArena())。

また _resolve() 内で GestureArenaMember.rejectGesture() が呼び出されている。

_resolve_3.png

_resolve()

_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()

_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 の状況に応じて使い分けられている。

Arena に存在する Recognizer が1つの場合 → デフォルトの解決
if (state.members.length == 1) {
  scheduleMicrotask(() => _resolveByDefault(pointer, state));
}

この場合、Arena に残る Recognizer を勝者として解決を行う。

現在進行中の処理に影響を与えないように、次のイベントループサイクルに _resolveByDefault() の実行を後回ししている。

Arena に Recognizer が存在しない場合 → Arena を削除
else if (state.members.isEmpty) {
  _arenas.remove(pointer);
}

その pointer における Arena を削除する。

eagerWinner が存在する場合 → 即ちに解決
else if (state.eagerWinner != null) {
  _resolveInFavorOf(pointer, state, state.eagerWinner!);
}

"eagerWinner" を勝者として _resolveInFavorOf() を呼び出す。

_resolveByDefault()

_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 = ~に有利な方向で、~を支持する

_resolveInFavorOf()
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
Listener(
  onPointerDown: (PointerDownEvent event) {},
  onPointerMove: (PointerMoveEvent event) {},
  onPointerUp: (PointerUpEvent event) {},
  onPointerCancel: (PointerCancelEvent event) {},
  child: ...,
)

Listener はジェスチャーとして認識される前の Pointer Event を監視することができるウィジェットで、 GestureDetector よりも低レベルのイベントを扱うことができる。つまり GestureDetectorListener をラップしたウィジェットである。

GestureDetector
GestureDetector(
  onTap:  () {},
  onTapDown: (TapDownDetails details) {},
  onTapUp: (TapUpDetails details) {},
  onTapCancel: () {},
)

Listener は Pointer Event を監視できる。
GestureDetector はジェスチャーを検知できる。

Pointer Event について調べようとした際に、GestureDetector のコールバックに print() などを仕込むと、生の Event を取得することはできない。未加工の情報を取得したければ、必ず Listener を使用するようにする。

MouseRegion

MouseRegion によってボタンが押されていないときでもマウスの動きを追跡することができる。

GestureDetector

GestureDetector に設定できる onTapDownGesture 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

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 によって検出されるという挙動をとる。

この挙動を検証してみた。

使用するサンプルはこちら。

deferToChild の挙動を検証する
class MyGestureDetector extends StatelessWidget {
  const MyGestureDetector({super.key});

  @override
  Widget build(BuildContext context) {
    return Container(
      color: Colors.red,
      height: 400,
      width: 400,
    );
  }
}

以下のような赤い正方形を使用する。

defer_to_child.png

まずこの赤い四角形を GestureDetector でラップし、GestureDetectorの Hit Test による被検出を、この赤い Container に委譲する設定を行う。

behaviorデフォルトで既に deferToChild になっているはずだが、あえて明示的に指定を行う。

Hit Test を child に委譲
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 で検出された時だけ GestureDetectoronTapDown も連動して反応した。

defer_to_child_1.gif

次に、以下のような構成に変更して、存在しない child へ Hit Test を委譲する形にしてみた。

Hit Test を委譲する child が存在しない
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 が存在しない
      ),
    );
  }
}

すると、GestureDetectoronTapDown が反応しなくなった。Hit Test によって検出されるウィジェットが存在しなくなったためと考えられる。

defer_to_child_2.gif

child を持たない GestureDetector が描画されていないのでは?という疑問が湧いたため、一応確認をしてみたところ、GestureDetector ウィジェット自体は、下記画像のように、確かに画面上に 400 x 400 px の領域を取って存在していた。

defer_to_child_2.png

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.

GestureDetectorPadding の余白部分などの透明な領域を含んでいても、その領域を不透明扱い(opaque)とすることにより Hit Test で検出できるようにする。

Flutter において、Padding の余白に代表される「何も描画されない状態、領域」は、「透明」と表現されることがある(特に Flutter公式サイト)。

この文脈における「透明」とは、単に Colors.transparentColor.fromARGB(0, 0, 0, 0) などのカラーを指すものではない。そのため、挙動の違いを検証するために、これらの色を指定しても、「透明」の領域を作ったことにならない場合があるため注意。

opaque を使用した場合の効果を検証してみた。

使用するサンプルはこちら。

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.png

まず deferToChild を明示的に指定して、赤い領域やその余白領域の反応を確認してみる(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.deferToChild,  // デフォルトの挙動を確認
          onTapDown: (_) => debugPrint('tap down'),
          child: Padding(
            padding: const EdgeInsets.all(50),
            child: Container(
              color: Colors.red,
              height: 300,
              width: 300,
            ),
          ),
        ),
      ],
    );
  }
}

Padding の余白領域の onTapDown は反応していなかった。

opaque_1.gif

ここで deferToChildopaque に変更してみる。

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_2.gif

またopaque には、視覚的に背後に配置されたウィジェットの Hit Test による検出を妨げる効果がある。

これについても検証してみる。

今回は、赤い GestureDetector の「余白」が背後の青い GestureDetector の Hit Test を妨げるかどうかだけを見たいので、先程のサンプルの青い領域にのみ onTapDown を設定しておく。

まずはデフォルト値の deferToChild から。特に余白領域のタップに注目する。

背後のウィジェットの検出を妨げる(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 の検出を妨げなかった。

opaque_3.gif

deferToChildopaque に変更してみる。

背後のウィジェットの検出を妨げる(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 による検出が妨げられたためと考えられる。

opaque_4.gif

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 の時と同じ)。

translucent の挙動を検証する
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.png

opaque で検証した時の挙動をおさらいする。

赤い GestureDetector の余白領域の背後の青い GestureDetector は、 前方の赤い GestureDetector に設定した opaque によって Hit Test で検出されなくなるため、タップに反応しない。

背後のウィジェットの検出を妨げる(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,
            ),
          ),
        ),
      ],
    );
  }
}

opaque_4.gif

これを translucent に変更してみる。

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 によって検出されるようになったためと考えられる。

translucent.gif

その他

引数 内容
excludeFromSemantics セマンティクス(アクセシビリティツールでの操作情報)からこのウィジェットを除外するかどうかの指定
- true
- false(デフォルト)
supportedDevices GestureDetector が反応するデバイスを制限するためのセット
trackpadScrollCausesScale トラックパッドのスクロールジェスチャーをスケール操作として扱うかどうか
- true
- false(デフォルト)
trackpadScrollToScaleFactor トラックパッドスクロールによるスケール操作の倍率
(デフォルト値)kDefaultTrackpadScrollToScaleFactor = Offset(0, -1/200)
1
2
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
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?