Flutter Event Propagation and Gesture Recognition Mechanism (Source Code Analysis)
Flutter converts native MotionEvent data into a serialized ByteBuffer, passes it through JNI to the engine, creates a Dart PointerDataPacket, hit‑tests the render tree, dispatches the event to each hit object, and resolves competing GestureRecognizers in the arena so the winning recognizer triggers the appropriate callback such as onTap or onDrag.
Flutter is a cross‑platform UI framework where gesture recognition is implemented in the Dart layer. Platform‑specific native views collect raw pointer events, normalize the differences, and convert them into Flutter‑recognizable event objects.
On Android the event flow is Java → C++ → Dart . The FlutterView (a FrameLayout ) receives a MotionEvent , extracts key data (type, coordinates, timestamp, etc.), serialises it into a ByteBuffer , and forwards the buffer to the engine via JNI.
/*AndroidTouchProcessor.java*/
private void addPointerForIndex(MotionEvent event, int pointerIndex, int pointerChange,
int pointerData, Matrix transformMatrix, ByteBuffer packet) {
long motionEventId = 0;
if (trackMotionEvents) {
MotionEventTracker.MotionEventId trackedEvent = motionEventTracker.track(event);
motionEventId = trackedEvent.getId();
}
int pointerKind = getPointerDeviceTypeForToolType(event.getToolType(pointerIndex));
int signalKind = event.getActionMasked() == MotionEvent.ACTION_SCROLL
? PointerSignalKind.SCROLL : PointerSignalKind.NONE;
long timeStamp = event.getEventTime() * 1000; // Convert from ms to µs
packet.putLong(motionEventId);
packet.putLong(timeStamp);
packet.putLong(pointerChange);
packet.putLong(event.getPointerId(pointerIndex));
packet.putLong(0); // pointer_identifier
float viewToScreenCoords[] = {event.getX(pointerIndex), event.getY(pointerIndex)};
transformMatrix.mapPoints(viewToScreenCoords);
packet.putDouble(viewToScreenCoords[0]); // x
packet.putDouble(viewToScreenCoords[1]); // y
...
packet.putLong(buttons);
...
packet.putDouble(0.0); // scroll_delta_x
packet.putDouble(0.0); // scroll_delta_y
}After the native side finishes processing, the byte buffer is sent to the engine through a JNI method:
@UiThread
public void dispatchPointerDataPacket(@NonNull ByteBuffer buffer, int position) {
ensureRunningOnMainThread();
ensureAttachedToNative();
nativeDispatchPointerDataPacket(nativePlatformViewId, buffer, position);
}
private native void nativeDispatchPointerDataPacket(long nativePlatformViewId,
@NonNull ByteBuffer buffer, int position);The native implementation extracts the raw data and creates a PointerDataPacket which is handed to the Flutter engine:
//platform_view_android_jni_impl.cc
static void DispatchPointerDataPacket(JNIEnv* env,
jobject jcaller,
jlong shell_holder,
jobject buffer,
jint position) {
uint8_t* data = static_cast
(env->GetDirectBufferAddress(buffer));
auto packet = std::make_unique
(data, position);
ANDROID_SHELL_HOLDER->GetPlatformView()->DispatchPointerDataPacket(std::move(packet));
}In the engine, the packet is forwarded to the Dart side via the window object:
void Window::DispatchPointerDataPacket(const PointerDataPacket& packet) {
std::shared_ptr
dart_state = library_.dart_state().lock();
if (!dart_state) return;
tonic::DartState::Scope scope(dart_state);
const std::vector
& buffer = packet.data();
Dart_Handle data_handle = tonic::DartByteData::Create(buffer.data(), buffer.size());
if (Dart_IsError(data_handle)) return;
tonic::DartInvokeField(library_.value(), "_dispatchPointerDataPacket", {data_handle});
}
@pragma('vm:entry-point')
void _dispatchPointerDataPacket(ByteData packet) {
if (window.onPointerDataPacket != null)
_invoke1
(window.onPointerDataPacket,
window._onPointerDataPacketZone,
_unpackPointerDataPacket(packet));
}Once the data reaches the Dart layer, it is delivered to GestureBinding where the hit‑testing and dispatching logic resides.
1. Hit testing
The GestureBinding.hitTest() first adds itself to the result, then delegates to RenderBinding.hitTest() which starts from the root RenderView and recursively asks each RenderBox to test itself and its children. A RenderBox contributes to the HitTestResult only if the pointer position lies inside its layout bounds.
/**GestureBinding*/
@override
void hitTest(HitTestResult result, Offset position) {
result.add(HitTestEntry(this)); // add self as a target
}
/**RenderBinding*/
@override
void hitTest(HitTestResult result, Offset position) {
assert(renderView != null);
renderView.hitTest(result, position: position);
super.hitTest(result, position);
}
/**RenderBox*/
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;
}2. Event dispatch
After hit‑testing, GestureBinding.dispatchEvent() iterates over every entry in the HitTestResult and calls handleEvent() on the corresponding target.
void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) {
if (hitTestResult == null) {
try { pointerRouter.route(event); } catch (e, s) {}
return;
}
for (final HitTestEntry entry in hitTestResult.path) {
try { entry.target.handleEvent(event.transformed(entry.transform), entry); }
catch (e, s) {}
}
}Most RenderObject s have an empty handleEvent implementation; concrete widgets add listeners (e.g., RenderPointerListener ) to react to events.
3. Gesture arena
When a PointerDownEvent arrives, the arena is closed, which starts the competition among all registered GestureRecognizer s. Recognisers can either acceptGesture or rejectGesture . The arena manager decides the winner based on the first recogniser that accepts or when only one remains.
/**GestureArenaManager*/
void close(int pointer) {
final _GestureArena? state = _arenas[pointer];
if (state == null) return;
state.isOpen = false; // close arena
_tryToResolveArena(pointer, state);
}
void _resolveByDefault(int pointer, _GestureArena state) {
if (!_arenas.containsKey(pointer)) return;
assert(state.isOpen == false);
assert(state.members.length == 1);
_arenas.remove(pointer);
state.members.first.acceptGesture(pointer);
}The following two practical scenarios illustrate how the arena resolves conflicts:
Scenario 1 – Tap vs. Drag
When the pointer moves beyond the tap slop, TapGestureRecognizer rejects itself, allowing DragGestureRecognizer to accept and become the winner.
void handleEvent(PointerEvent event) {
if (state == GestureRecognizerState.possible && event.pointer == primaryPointer) {
if (event is PointerMoveEvent && (isPreAcceptSlopPastTolerance || isPostAcceptSlopPastTolerance)) {
resolve(GestureDisposition.rejected);
stopTrackingPointer(primaryPointer!);
} else {
handlePrimaryPointer(event);
}
}
stopTrackingIfPointerNoLongerDown(event);
} @override
void handleEvent(PointerEvent event) {
if (event is PointerMoveEvent) {
if (_hasSufficientGlobalDistanceToAccept(event.kind))
resolve(GestureDisposition.accepted);
}
if (event is PointerUpEvent || event is PointerCancelEvent) {
_giveUpPointer(event.pointer, reject: event is PointerCancelEvent || _state == _DragState.possible);
}
}Scenario 2 – Tap vs. Double‑Tap
The double‑tap recogniser holds the arena after the first tap, preventing the arena from being swept until either a second tap arrives within the timeout or the timer expires.
void _handleEvent(PointerEvent event) {
final _TapTracker tracker = _trackers[event.pointer]!;
if (event is PointerUpEvent) {
if (_firstTap == null) _registerFirstTap(tracker);
else _registerSecondTap(tracker);
} else if (event is PointerMoveEvent) {
if (!tracker.isWithinGlobalTolerance(event, kDoubleTapTouchSlop)) _reject(tracker);
} else if (event is PointerCancelEvent) {
_reject(tracker);
}
}
void _registerFirstTap(_TapTracker tracker) {
_startDoubleTapTimer();
GestureBinding.instance!.gestureArena.hold(tracker.pointer);
_freezeTracker(tracker);
_trackers.remove(tracker.pointer);
_firstTap = tracker;
}
void _reset() {
_stopDoubleTapTimer();
if (_firstTap != null) {
_reject(_firstTap!);
GestureBinding.instance!.gestureArena.release(_firstTap!.pointer);
_firstTap = null;
}
_clearTrackers();
}In summary, Flutter collects native pointer data, normalises it, and routes it through the engine to the Dart GestureBinding . The binding performs a hit‑test to gather all potential responders, dispatches the event to each, and uses the gesture arena to let recognisers compete. The winning recogniser finally invokes the callbacks supplied to GestureDetector (e.g., onTap , onDrag ).
Tencent Cloud Developer
Official Tencent Cloud community account that brings together developers, shares practical tech insights, and fosters an influential tech exchange community.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.