Flutterのタッチイベント処理探訪① PointerEvent
私達がFlutterを書いているとき、何気なく使っているButton
やInkWell
、GestureDetector
などは裏でどのようなことをしているのでしょうか?
実装を読みながら処理の流れをなぞっていこうと思います。
タッチイベント処理の概要
大まかには公式に以下のような説明が存在しています。
2つのレイヤー
Flutterではタッチイベントを処理するためにPointerレイヤーとGestureレイヤーの2つのレイヤーに分けています。
1.Pointerレイヤー
デバイスから取得したタッチイベントをほぼそのままFlutterのツリーに伝えるレイヤーです。Listener
Widgetを使うとPointerレイヤーのイベントであるポインターダウン、移動、ポインターアップなどをコールバックから得ることができます。
本記事ではこちらをなぞっていきます。
2.Gestureレイヤー
こちらはPointerレイヤーの上に構築された上位レイヤーで、タップやドラッグ、ピンチなどの複雑なジェスチャーを検知できるようになっています。おなじみGestureDetector
によりGestureレイヤーの結果をコールバックから得ることができます。
GestureレイヤーはGestureArena(ジェスチャー闘技場)で検知したいジェスチャー同士を戦わせるという面白い実装をしているのですが、この話は別記事に回します。
追記: 次の記事↓
概要は他に書いていた人がいるのでそれを貼っておきます
Pointerレイヤーの詳細
ではPointerレイヤーがどのように実装されているのかをこれから追うことにしましょう。
PointerEventの源泉
https://docs.flutter.dev/resources/architectural-overview より
上の図にあるように、FlutterというのはFramework、Engine、Embedderの3層で構成されています。
タッチイベントというのは各プラットフォームから提供されるものなので、
Embedder -> Engine -> Framework
という順でイベントが伝搬されていると考えられます。
Frameworkのコードは
Engine/Embedderのコードは
から読むことができます。
Embedderはプラットフォームにより実装が分かれているので、読みやすいGLFWバックエンドのコードでのタッチイベントの発生を探すと、flutter_glfw.cc
に以下の記述が見つかります。
static void GLFWMouseButtonCallback(GLFWwindow* window,
int key,
int action,
int mods) {
int64_t button;
if (key == GLFW_MOUSE_BUTTON_LEFT) {
button = FlutterPointerMouseButtons::kFlutterPointerButtonMousePrimary;
} else if (key == GLFW_MOUSE_BUTTON_RIGHT) {
button = FlutterPointerMouseButtons::kFlutterPointerButtonMouseSecondary;
} else {
return;
}
auto* controller = GetWindowController(window);
controller->buttons = (action == GLFW_PRESS) ? controller->buttons | button
: controller->buttons & ~button;
FlutterPointerEvent event = {};
SetEventPhaseFromCursorButtonState(window, &event, controller->buttons);
SetEventLocationFromCursorPosition(window, &event);
SendPointerEventWithData(window, event);
//...
}
このGLFWMouseButtonCallback()
関数はGLFWのマウスクリックを通知するglfwSetMouseButtonCallback
に別の場所で登録しているため、クリックするとこのコールバックが呼ばれることになります。そして最後の方にSendPointerEventWithData()
を呼び出してイベントを送っていることがわかります。
static void SendPointerEventWithData(GLFWwindow* window,
const FlutterPointerEvent& event_data) {
//...
FlutterPointerEvent event = event_data;
//...
FlutterEngineSendPointerEvent(controller->engine->flutter_engine, &event, 1);
//...
}
PointerEventを少し弄ってからEmbedderのFlutterEngineSendPointerEvent()
を呼び出していることがわかります(ここからEmbedder共通処理)。
FlutterEngineResult FlutterEngineSendPointerEvent(
FLUTTER_API_SYMBOL(FlutterEngine) engine,
const FlutterPointerEvent* pointers,
size_t events_count) {
//...
auto packet = std::make_unique<flutter::PointerDataPacket>(events_count);
//...
return reinterpret_cast<flutter::EmbedderEngine*>(engine)
->DispatchPointerDataPacket(std::move(packet))
? kSuccess
: LOG_EMBEDDER_ERROR(kInternalInconsistency,
"Could not dispatch pointer events to the "
"running Flutter application.");
}
最後にEmbedderEngine.DispatchPointerDataPacket()
を呼び出しています。
このあと長いのでコールスタックだけ書くと、
EmbedderEngine.DispatchPointerDataPacket()
-> PlatformView.DispatchPointerDataPacket()
-> Shell.OnPlatformViewDispatchPointerDataPacket()
-> Engine.DispatchiPointerDataPacket()
-> DefaultPointerDataDispatcher.DispatchPacket()
-> Engine.DoDispatcherPacket()
-> RuntimeController.DispatchPointerDataPacket()
-> Window.DispatchPointerDataPacket()
のようになります。
void Window::DispatchPointerDataPacket(const PointerDataPacket& packet) {
std::shared_ptr<tonic::DartState> dart_state = library_.dart_state().lock();
if (!dart_state) {
return;
}
tonic::DartState::Scope scope(dart_state);
const std::vector<uint8_t>& buffer = packet.data();
Dart_Handle data_handle =
tonic::DartByteData::Create(buffer.data(), buffer.size());
if (Dart_IsError(data_handle)) {
return;
}
tonic::LogIfError(tonic::DartInvokeField(
library_.value(), "_dispatchPointerDataPacket", {data_handle}));
}
Window.DispatchPointerDataPacket()
からDartInvokeを使いDartの関数を呼び出しています。
呼び出し先はui/hooks.dart
に実装があります。
('vm:entry-point')
void _dispatchPointerDataPacket(ByteData packet) {
PlatformDispatcher.instance._dispatchPointerDataPacket(packet);
}
さらにPlatformDispatcher
の呼び出し先は以下になります。
void _dispatchPointerDataPacket(ByteData packet) {
if (onPointerDataPacket != null) {
_invoke1<PointerDataPacket>(
onPointerDataPacket,
_onPointerDataPacketZone,
_unpackPointerDataPacket(packet),
);
}
}
呼び出している'PlatformDispatcher.onPointerDataPacket()'にはGestureBinding._handlePointerDataPacket()
が割り当てられています。ここでやっとFlutterの世界にPointerのイベントがやってきました(PointerDataPacketと呼ばれていますね)。長い道のりでしたが基本イベントのデータを渡しているだけなので、PointerDown、PointerMove、PointerUpなどがプラットフォームで検知されるたびにこの関数が呼ばれることになります。
mixin GestureBinding on BindingBase implements HitTestable, HitTestDispatcher, HitTestTarget {
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.
_pendingPointerEvents.addAll(PointerEventConverter.expand(packet.data, window.devicePixelRatio));
if (!locked)
_flushPointerEventQueue();
}
GestureBinding
の初期化の挙動については以下の記事を参照してください。
PointerDataPacketの処理
ではGestureBinding
上では受け取ったPointerDataPacket
をどのように処理しているのでしょうか。
もう一度_handlePointerDataPacket()
を見ると、PointerEvent
という型に変換したものを一度キューに保存し、その後_flushPointerEventQueue()
で1つずつ処理していることがわかります。
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.
_pendingPointerEvents.addAll(PointerEventConverter.expand(packet.data, window.devicePixelRatio));
if (!locked)
_flushPointerEventQueue();
}
void _flushPointerEventQueue() {
assert(!locked);
while (_pendingPointerEvents.isNotEmpty)
handlePointerEvent(_pendingPointerEvents.removeFirst());
}
よって個々のPointerEvent
はGestureBinding.handlePointerEvent()
でこね回すことになります。中では_handlePointerEventImmediately()
を呼んでいますね。
void handlePointerEvent(PointerEvent event) {
assert(!locked);
if (resamplingEnabled) {
_resampler.addOrDispatch(event);
_resampler.sample(samplingOffset, _samplingClock);
return;
}
// Stop resampler if resampling is not enabled. This is a no-op if
// resampling was never enabled.
_resampler.stop();
_handlePointerEventImmediately(event);
}
PointerEventとは
少し脱線して、まずFlutterで扱っているPointerEvent
というかたまりについて考えます。
PointerEvent
自体は画面のどこに指が触れているのか、という点の情報のみを持っています。一方PointerEvent.pointer
には数字が割り振られていて、上の図のようにpointerが同じものはドラッグや長押しなど、ポインターが押されてから離れるまでの同じ指であることを示しています。
このため複数の指などによるPointerEvent
が来たとしても、どの指がどこへ移動したのかをFlutter側が正確に把握することができるわけです。
先程分類していたUp、Down、Moveなどの分類はそれぞれPointerEvent
を継承したクラスが流れてくることで区別できます。
また、上の図でも示すとおり、PointerEvent
はDownした地点が検知範囲内であればトラッキングをするので、MoveやUpは範囲をはみ出す可能性があります。
HitTestResultによるイベントの処理先の管理
戻ってPointerEvent
を処理するGestureBinding._handlePointerEventImmediately()
では、pointerごとにHitTestResult
というオブジェクトを作成します。
HitTestResult
はpointerの位置に存在する画面の要素を全て記憶しておくことで、pointerのイベントをそれらの要素に伝える役割を持っています。
他は無視するとして、PointerDownEvent
を受け取った場合は初見のpointerであるため、_hitTests
というMapにpointerをキーにしてHitTestResult
を新しく作ります。
HitTestResult? hitTestResult;
if (event is PointerDownEvent || event is PointerSignalEvent || event is PointerHoverEvent) {
assert(!_hitTests.containsKey(event.pointer));
hitTestResult = HitTestResult();
hitTest(hitTestResult, event.position);
if (event is PointerDownEvent) {
_hitTests[event.pointer] = hitTestResult;
}
//...
}
次に初期化のためhitTest()
を呼んでいます。GestureBinding.hitTest()
を見ると
void hitTest(HitTestResult result, Offset position) {
result.add(HitTestEntry(this));
}
あれ?GestureBinding
自身のみを追加して終わっていますね。HitTestResult
には該当する全ての要素を追加しておくはずなのに...
(脱線)mixinの罠
はい、実はここでmixinの魔法がかかっています。
https://zenn.dev/fastriver/articles/65a1b96911c86e#widgetsflutterbindingでの使われ方
忘れがちですがGestureBinding
のインスタンスはWidgetsFlutterBinding
で、mixinは図の上から下にoverrideするような形になるためmixinのどこかに同名のメソッドがあるとそちらが優先的に呼ばれるようになっています。
探してみると、ありましたよ。RendererBinding
に
void hitTest(HitTestResult result, Offset position) {
assert(renderView != null);
assert(result != null);
assert(position != null);
renderView.hitTest(result, position: position);
super.hitTest(result, position);
}
こちらでrenderView.hitTest()
を呼び出すことがわかります。またsuper呼び出しを持っているため、GestureBinding
の方も続いて呼ばれるようになっています。
RenderObjectツリーの走査
落ち着いたところで次を見ていきましょう。RenderView.hitTest()
は
bool hitTest(HitTestResult result, { required Offset position }) {
if (child != null)
child!.hitTest(BoxHitTestResult.wrap(result), position: position);
result.add(HitTestEntry(this));
return true;
}
自身を追加して子のhitTestを呼び出しているだけですね。ここで呼び出されているRenderBox.hitTest()
は
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;
}
hitTestChildren()
とhitTestSelf()
のどちらかがヒットすれば自身を追加する、としています。
hitTestChildren()
では、子を持つ場合は子の位置だけ判定をずらしてから子のhitTest()
を呼ぶようにしているようです。いずれかの子がヒットすれば返り値がtrueになります。
hitTestSelf()
では自身がHitTestの対象になるかを返します。デフォルトではfalseですがHitTestに含めたいもの、例えばRenderListTile
(ListTile
のRenderObject)はtrueを返します。
このhitTest()
を末端まで繰り返すことでpointerの位置に応じたHitTestのリストが得られます。またhitTest()
のコードの通り、子の方が先に追加される(=末端の要素がリストの前方に来る)ようになっています。
イベントをツリーに適用する
長くなりましたが、どこを見ていたのかというとGestureBinding._handlePointerEventImmediately()
でした。HitTestResult
の初期化が終了する(orすでに作成されたHitTestResult
を取得する)と、次はdispatchEvent()
を呼んで次の処理に向かいます。
// in _handlePointerEventImmediately()
if (hitTestResult != null ||
event is PointerAddedEvent ||
event is PointerRemovedEvent) {
assert(event.position != null);
dispatchEvent(event, hitTestResult);
}
GestureBinding.dispatchEvent()
は主要な部分を抜き出すと以下の処理をしています。
void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) {
//...
for (final HitTestEntry entry in hitTestResult.path) {
try {
entry.target.handleEvent(event.transformed(entry.transform), entry);
} //...
}
}
hitTestResult.path
は先程RenderObjectツリーを走査して集めたRenderObjectのリストです。よってそれらのhandleEvent()
を順に呼んでいるということになります。
このメソッド、デフォルトでは空なので実装の中身のない場合も多いです。使っているものは例えばRenderMouseRegion
では、マウスのHoverを検知するためにhandleEvent()
を利用しています。
最も重要なのはRenderPointerListener.handleEvent()
です。
void handleEvent(PointerEvent event, HitTestEntry entry) {
assert(debugHandleEvent(event, entry));
if (event is PointerDownEvent)
return onPointerDown?.call(event);
if (event is PointerMoveEvent)
return onPointerMove?.call(event);
if (event is PointerUpEvent)
return onPointerUp?.call(event);
if (event is PointerHoverEvent)
return onPointerHover?.call(event);
if (event is PointerCancelEvent)
return onPointerCancel?.call(event);
if (event is PointerSignalEvent)
return onPointerSignal?.call(event);
}
これはListener
ウィジェットのRenderObjectで、流れてきたPointerEvent
の種類から登録されたコールバックを実行するようになっています。
上の例を見ると、Listener
によりPointerDownEvent
が処理できている様子がわかります。またhandleEvent()
はツリーの先端から根まで全て呼び出すため、中の四角を押したときでも外のListener
も同時に反応する(順序では内側の方が先に呼ばれる)ということも確認できると思います。
GestureBinding
もHitTest登録してませんでしたっけ?
GestureBinding.hitTest()
では自分自身をHitTestResult
に登録しています。これはつまりdispatchEvent()
を実行すると必ず最後にGestureBinding.handleEvent()
が呼ばれるということです。
色々なものが動いている気配がありますが、これはGestureレイヤーのタスクなので割愛します。
Pointerレイヤーまとめ
- Pointerレイヤーではプラットフォームから受け取った
PointerEvent
を該当するRenderObjectに流す -
PointerEvent
はpointerにより動きを管理している -
Listener
ウィジェットによりPointerEvent
を監視できる -
Listener
ウィジェットは重ねると全て反応する
実際にPointerレイヤーを直接いじることはなく、上位のGestureレイヤーにて構築されるGestureDetector
やボタンなどを利用するのみと思います。
次回はGestureレイヤーを紹介します...
Discussion