👆

Flutterのタッチイベント処理探訪① PointerEvent

2022/03/17に公開

私達がFlutterを書いているとき、何気なく使っているButtonInkWellGestureDetectorなどは裏でどのようなことをしているのでしょうか? 
実装を読みながら処理の流れをなぞっていこうと思います。

タッチイベント処理の概要

大まかには公式に以下のような説明が存在しています。

https://docs.flutter.dev/development/ui/advanced/gestures

2つのレイヤー

Flutterではタッチイベントを処理するためにPointerレイヤーGestureレイヤーの2つのレイヤーに分けています。

1.Pointerレイヤー

デバイスから取得したタッチイベントをほぼそのままFlutterのツリーに伝えるレイヤーです。Listener Widgetを使うとPointerレイヤーのイベントであるポインターダウン、移動、ポインターアップなどをコールバックから得ることができます。

本記事ではこちらをなぞっていきます。

2.Gestureレイヤー

こちらはPointerレイヤーの上に構築された上位レイヤーで、タップやドラッグ、ピンチなどの複雑なジェスチャーを検知できるようになっています。おなじみGestureDetectorによりGestureレイヤーの結果をコールバックから得ることができます。

GestureレイヤーはGestureArena(ジェスチャー闘技場)で検知したいジェスチャー同士を戦わせるという面白い実装をしているのですが、この話は別記事に回します。

追記: 次の記事↓

https://zenn.dev/fastriver/articles/cb8f5a2a019715

概要は他に書いていた人がいるのでそれを貼っておきます

https://qiita.com/mjhd/items/8e300238e494d8755b44

Pointerレイヤーの詳細

ではPointerレイヤーがどのように実装されているのかをこれから追うことにしましょう。

PointerEventの源泉

https://docs.flutter.dev/resources/architectural-overview より

上の図にあるように、FlutterというのはFramework、Engine、Embedderの3層で構成されています。

タッチイベントというのは各プラットフォームから提供されるものなので、
Embedder -> Engine -> Framework
という順でイベントが伝搬されていると考えられます。
Frameworkのコードは

https://github.com/flutter/flutter

Engine/Embedderのコードは

https://github.com/flutter/engine

から読むことができます。

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

https://github.com/flutter/engine/blob/7fe613a77bd2ea0d063977d5baa8ead13fa3cf4b/shell/platform/glfw/flutter_glfw.cc#L378

このGLFWMouseButtonCallback()関数はGLFWのマウスクリックを通知するglfwSetMouseButtonCallbackに別の場所で登録しているため、クリックするとこのコールバックが呼ばれることになります。そして最後の方にSendPointerEventWithData()を呼び出してイベントを送っていることがわかります。

static void SendPointerEventWithData(GLFWwindow* window,
                                     const FlutterPointerEvent& event_data) {
  //...
  FlutterPointerEvent event = event_data;
  //...
  FlutterEngineSendPointerEvent(controller->engine->flutter_engine, &event, 1);
  //...
}

https://github.com/flutter/engine/blob/7fe613a77bd2ea0d063977d5baa8ead13fa3cf4b/shell/platform/glfw/flutter_glfw.cc#L283

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

https://github.com/flutter/engine/blob/7fe613a77bd2ea0d063977d5baa8ead13fa3cf4b/shell/platform/embedder/embedder.cc#L1737

最後に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);
}

https://github.com/flutter/engine/blob/7fe613a77bd2ea0d063977d5baa8ead13fa3cf4b/lib/ui/hooks.dart#L93

さらにPlatformDispatcherの呼び出し先は以下になります。

  void _dispatchPointerDataPacket(ByteData packet) {
    if (onPointerDataPacket != null) {
      _invoke1<PointerDataPacket>(
        onPointerDataPacket,
        _onPointerDataPacketZone,
        _unpackPointerDataPacket(packet),
      );
    }
  }

https://github.com/flutter/engine/blob/7fe613a77bd2ea0d063977d5baa8ead13fa3cf4b/lib/ui/platform_dispatcher.dart#L329

呼び出している'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();
  }

https://github.com/flutter/flutter/blob/b4040c867b26097903aaf8814c6ee149400557c7/packages/flutter/lib/src/gestures/binding.dart#L261

GestureBindingの初期化の挙動については以下の記事を参照してください。

https://zenn.dev/fastriver/articles/65a1b96911c86e

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

よって個々のPointerEventGestureBinding.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というかたまりについて考えます。

https://api.flutter.dev/flutter/gestures/PointerEvent-class.html

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

https://api.flutter.dev/flutter/gestures/GestureBinding/hitTest.html

あれ?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);
  }

https://api.flutter.dev/flutter/rendering/RendererBinding/hitTest.html

こちらで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;
}

https://api.flutter.dev/flutter/rendering/RenderView/hitTest.html

自身を追加して子の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;
}

https://api.flutter.dev/flutter/rendering/RenderBox/hitTest.html

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

https://api.flutter.dev/flutter/rendering/RenderPointerListener/handleEvent.html

これはListenerウィジェットのRenderObjectで、流れてきたPointerEventの種類から登録されたコールバックを実行するようになっています。

上の例を見ると、ListenerによりPointerDownEventが処理できている様子がわかります。またhandleEvent()はツリーの先端から根まで全て呼び出すため、中の四角を押したときでも外のListenerも同時に反応する(順序では内側の方が先に呼ばれる)ということも確認できると思います。

GestureBindingもHitTest登録してませんでしたっけ?

GestureBinding.hitTest()では自分自身をHitTestResultに登録しています。これはつまりdispatchEvent()を実行すると必ず最後にGestureBinding.handleEvent()が呼ばれるということです。

https://api.flutter.dev/flutter/gestures/GestureBinding/handleEvent.html

色々なものが動いている気配がありますが、これはGestureレイヤーのタスクなので割愛します。

Pointerレイヤーまとめ

  • Pointerレイヤーではプラットフォームから受け取ったPointerEventを該当するRenderObjectに流す
  • PointerEventはpointerにより動きを管理している
  • ListenerウィジェットによりPointerEventを監視できる
  • Listenerウィジェットは重ねると全て反応する

実際にPointerレイヤーを直接いじることはなく、上位のGestureレイヤーにて構築されるGestureDetectorやボタンなどを利用するのみと思います。

次回はGestureレイヤーを紹介します...

https://zenn.dev/fastriver/articles/cb8f5a2a019715

Discussion