この記事は Flutter Advent Calendar 2025 21 日目の記事です。
ElevatedButton をタップしたらコールバックが呼ばれる、ListView を上下にスワイプしたらスクロールする、InteractiveViewer でピンチイン・アウトすると拡大・縮小する、どれもアプリの挙動としては当たり前と認識しているこれらの仕組みですが、Flutter フレームワークがどのようにユーザーのタッチ操作を受け取り適切な Widget にそのイベントを届けているのか、ご存知でしょうか。
「Flutter フレームワークが」とは言っても具体的にはフレームワーク層に Dart で実装されたプログラムです。ということはつまり「タッチイベントを受け取って適切な Widget に届ける」プログラムがわれわれにも読める形でそこにあるはずです。
というわけでこの記事では、そのプログラムを追いながら Flutter の "Hit Test" がどのような仕組みで成り立っているのかを考えてみたいと思います。
タッチイベントのエントリーポイント
まずは調査として、GestureDetector の onTap メソッドなどにブレークポイントを貼って呼び出し元を辿ってみましょう。
コールスタックをたくさん遡ると、ユーザーが画面を触った時にまず呼ばれるのは hooks.dart の _dispatchPointerDataPacket() メソッドであることがわかります。
@pragma('vm:entry-point')
void _dispatchPointerDataPacket(ByteData packet) {
PlatformDispatcher.instance._dispatchPointerDataPacket(packet);
}
hooks.dart は @pragma('vm:entry-point') がついているように Dart VM から直接呼び出される関数で、ユーザーのタッチイベントがあった際にまず呼び出されるのがこのメソッドです。1
ここをスタート地点として、そのまま素直に処理を読み進めてみましょう。
_dispatchPointerDataPacket() 関数の中では PlatformDispatcher.instance._dispatchPointerDataPacket() という同名のメソッドが呼び出されていますので、そちらにジャンプします。
実装はとてもシンプルで、以下のような処理になっています。
void _dispatchPointerDataPacket(ByteData packet) {
if (onPointerDataPacket != null) {
_invoke1<PointerDataPacket>(
onPointerDataPacket,
_onPointerDataPacketZone,
_unpackPointerDataPacket(packet),
);
}
}
ここで _invoke1 自体は「第1引数に受け取った関数を実行する」くらいの役割(今回関心のある範囲では)ですので、実際に実行されている onPointerDataPacket をさらにみていきます。
class PlatformDispatcher {
PointerDataPacketCallback? get onPointerDataPacket => _onPointerDataPacket;
PointerDataPacketCallback? _onPointerDataPacket;
set onPointerDataPacket(PointerDataPacketCallback? callback) {
_onPointerDataPacket = callback;
_onPointerDataPacketZone = Zone.current;
}
}
少し getter や setter などを経由しつつ、具体的な処理は binding.dart で以下のように実装されています。
mixin GestureBinding on BindingBase implements HitTestable, HitTestDispatcher, HitTestTarget {
@override
void initInstances() {
super.initInstances();
_instance = this;
platformDispatcher.onPointerDataPacket = _handlePointerDataPacket;
}
void _handlePointerDataPacket(ui.PointerDataPacket packet) {
// We convert pointer data to logical pixels so that e.g. the touch slop can be
// defined in a device-independent manner.
try {
_pendingPointerEvents.addAll(
PointerEventConverter.expand(packet.data, _devicePixelRatioForView),
);
if (!locked) {
_flushPointerEventQueue();
}
} catch (error, stack) {
// エラー処理は省略
}
}
}
引数として引きまわされている ui.PointerDataPacket はその名の通りタッチイベントの内容を保持するオブジェクトで、タッチの座標や「指が触った/離れた」などのイベント種別、他にはタッチの圧力などのデータが保持されています。
ちなみに座標は physicalX のような名前のフィールドで保持されていることからわかる通り、端末ごとの画面サイズで大きく異なる物理ピクセルで保持されています。
この _handlePointerDataPacket メソッドでは、処理の最初にコメントされている通り、この物理ピクセルを devicePixelRatio を考慮した論理ピクセルに直して Hit Test の処理を開始していることが伺えます。
Binding から RenderObject へ
ユーザーのタッチイベントを受け取り、物理ピクセルから論理ピクセルに変換したら、いくつかのメソッドを経由して以下の _handlePointerEventImmediately() が呼び出されます。2
mixin GestureBinding on BindingBase implements HitTestable, HitTestDispatcher, HitTestTarget {
void _handlePointerEventImmediately(PointerEvent event) {
HitTestResult? hitTestResult;
if (event is PointerDownEvent ||
event is PointerSignalEvent ||
event is PointerHoverEvent ||
event is PointerPanZoomStartEvent) {
// Hit Test の結果を格納する HitTestResult を作る
hitTestResult = HitTestResult();
// Hit Test を実行する。結果は hitTestResult に入る
hitTestInView(hitTestResult, event.position, event.viewId);
if (event is PointerDownEvent || event is PointerPanZoomStartEvent) {
_hitTests[event.pointer] = hitTestResult;
}
} else if (event is PointerUpEvent ||
event is PointerCancelEvent ||
event is PointerPanZoomEndEvent) {
hitTestResult = _hitTests.remove(event.pointer);
} else if (event.down || event is PointerPanZoomUpdateEvent) {
hitTestResult = _hitTests[event.pointer];
}
if (hitTestResult != null || event is PointerAddedEvent || event is PointerRemovedEvent) {
// Hit Test で選ばれた Widget にイベントの内容を転送
dispatchEvent(event, hitTestResult);
}
}
}
ここでようやく "Hit Test" という言葉が出てきました。hitTestInView() を呼び出してそのイベントを処理する Widget(実際は RenderObject)を特定し、その結果を hitTestResult に格納します。
その結果を元に、処理の最後にある dispatchEvent() で実際のイベント処理(ElevatedButton の onPressed の呼び出しなど)を行っているようです。
今回は Hit Test を深掘りしたいので、hitTestInView() をさらにみていきましょう。hitTestInView() は定義元にジャンプすると result.add(HitTestEntry(this)); しているだけのシンプルなコードが出てきますが、実際は(ブレークポイント等で止めて遡ってみると)以下の RenderBinding という mixin でオーバーライドされた hitTestInView() が呼ばれています。
mixin RendererBinding
@override
void hitTestInView(HitTestResult result, Offset position, int viewId) {
_viewIdToRenderView[viewId]?.hitTest(result, position: position);
super.hitTestInView(result, position, viewId);
}
}
_viewIdToRenderView() でルートの RenderObject を取得して hitTest を呼び出しています。このあたりからが RenderObject の担当になってきます。
RenderObject による Hit Test
ここからは、ひとつひとつの RenderObject に対して「このタッチイベントが発生した場所にいるのはあなたですか?」を尋ねて回るフェーズに入ります。
当然ながら、アプリ内に無数に存在する RenderObject ひとつひとつに対して順番に確認していては効率が悪いですので、Flutter では RenderObject ツリーの構造を活用して「祖先から子孫に絞り込む」形でタップ対象を絞り込んでいきます。その挙動をコードで確認してみましょう。
abstract class RenderBox extends RenderObject {
bool hitTest(BoxHitTestResult result, {required Offset position}) {
if (_size!.contains(position)) {
if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
result.add(BoxHitTestEntry(this, position));
return true;
}
}
return false;
}
}
RenderObject の基本的なタッチ判定の流れをざっくり列挙すると、
- もしタッチの発生が自分の領域内だったら(
_size!.contains(position)) - 子供を先に判定して(
hitTestChildren()) - 違ったら自分を判定する(
hitTestSelf())
ということになるかと思います。
これは以下のような UI を思い浮かべていただけるとわかりやすいでしょう。
このように「灰色の四角」>「ボタン」>「"Hit Test" というテキスト」という構成になっている画面で、たとえばボタンの場所をタッチした場合、灰色の四角を描画している RenderObject は以下のように Hit Test を進めます。
- 自分の領域内でタッチが発生したと判断
- 先に子供(
ElevatedBottun)に対してタッチ判定-
ElevatedButtonが自分の領域内でタッチが発生したと判断 - 先に子供(
Text)に対してタッチ判定-
Textが自分の領域内でタッチが発生したと判断 - ただし、
Textは子供もおらず自身でもタッチイベントを処理しないため、判定はfalse -
-
ElevatedButtonのタッチ判定に戻る
-
-
-
Textが違ったので自分(ElevatedButton) を判定する -
ElevatedButtonは自身でタッチイベントを処理するため、判定はtrue
-
- 子供(
ElevatedButton)がタッチ処理をすることがわかったため、「灰色の四角」自身はタッチ判定自体を行わずhitTest()の呼び出し元にtrueを返却 - 以降、大元の
RendererBindingまで処理を戻して担当が分かったことを伝える
という流れでタッチ処理の担当が ElevatedButton であることを確定させます。
われわれは普段当たり前のようにコーディングしていますが、ElevatedButton や GestureDetector などのタッチ判定を持つ Widget が入れ子になっている場合に一番末端の(つまり UI 的には一番手前に見える)Widget が自然にタップできるようになっているのも、この 「子の判定が先、自身の判定はその後」 の仕組みのおかげということがここから分かるのではないでしょうか。
いろいろな hitTest の実装
ここまでは主に RenderObject やその周辺に実装されている hitTest() 共通の実装を見てきましたが、hitTest() の具体的な実装は RenderObject のサブクラスごとにさまざまです。いくつか見てみましょう。
たとえば、「タッチイベントを無視したい」場合に使われる Widget として IgnorePainter がありますが、IgnorePainter に対応する RenderObject の RenderIgnorePointer を見てみると以下のようになっています。
class RenderIgnorePointer extends RenderProxyBox {
@override
bool hitTest(BoxHitTestResult result, {required Offset position}) {
return !ignoring && super.hitTest(result, position: position);
}
}
ignoring フラグが true (デフォルト値)である場合、最初の !ignoring の判定が false になってそれ以上自分自身も子に対しても Hit Test 自体を行いません。
問答無用で false が返却されるということは、IgnorePointer の子孫の Widget に対して一切の Hit Test が発生せず、さらには自身の判定も行われず無視(ignore)されるというわけです。
ただし祖先にタッチを処理する Widget があればそこが処理します。先ほどの例で、たとえば灰色の四角にタッチ判定をつけ、ElevatedButton を IgnorePointer の child にすると、以下のような挙動になります。
手前の Widget に対するタッチが無視され、奥の Widget が反応する、IgnorePointer のよくある使い方と言えるでしょうか。
他にも似た Widget として AbsorbPointer がありますが、こちらは以下のようになっています。
class RenderAbsorbPointer extends RenderProxyBox {
@override
bool hitTest(BoxHitTestResult result, {required Offset position}) {
return absorbing ? size.contains(position) : super.hitTest(result, position: position);
}
}
absorbing がデフォルト値の true の場合、RenderAbsorbPointer は「自身の領域内でタップされたかどうか」だけをチェックして子孫に対しては Hit Test を行いません。
つまりタップが自分の領域内で発生している場合は問答無用で自分がそれを処理することになる、というわけですね。IgnorePointer との違いとしては、自分自身のタッチ判定をするかどうか、という点になりそうです。
他にも Widget の見た目を拡大や縮小、回転や移動を行うTransform は、その見た目に応じて Hit Test の結果を変えるような実装になっており、こちらも面白いです(時間がなくて詳細は書けてないですが)。
Transform に関しては、これを使ったことのある方の中には「見た目は OK だけどタッチできない」問題に直面した経験のある方もいらっしゃるかもしれません。その理由については、ここまでの Hit Test の流れと照らし合わせて考えるともしかしたら理解しやすいのではないでしょうか。3
まとめ
ここまで、Flutter の Hit Test について自分が理解したことを書いてきました。
ちなみに「タッチ判定」という話題は Hit Test の他にも GestureArena によるタッチイベントの解釈だったり、PlatformView によるネイティブ UI が関わるタッチ判定だったり、WidgetTester を使った widget test や integration test 実行時のタッチ処理だったり多岐に渡りますが、この記事ではひとまず「タッチを Flutter が検出してから、どの Widget(RenderObject) がそれを処理するのかを決めるまで」についてみてみました。
あまりこの知識を普段のアプリ開発で活用する機会はないかもしれませんが、個人的にはこのあたりの仕組みを活用して gesture_recorder という「実行中のアプリでタッチを記録・再生する」パッケージを作ったり
animated_to の「アニメーション中に見た目とタッチ判定が合っていない」問題を解決したりできましたので、ここまで読んでいただいた方もいつかどこかで何かしらの形でこの知見が活きるかもしれません。


