Repository navigation
Description
Steps to reproduce
Minimal project attached (a bare PlatformViewLink + AndroidViewSurface over a plain Android View that only logs and draws the MotionEvents it receives — no WebView, no plugins).
On a physical Android device:
- Press and hold on the left half, moving in circles.
- With that finger still down, press and hold on the right half.
- Release the left finger only.
- Press the left half again.
Expected results
Each pointer keeps reporting its own finger's position. Pointer ids and coordinates stay paired.
Actual results
From step 4 on, the MotionEvents delivered to the platform view are corrupted:
- one pointer reports coordinates pixel-identical to the other finger (two real fingers cannot occupy the same pixel);
- the other pointer reports
2 * P_other - P_real— a point reflection through the other pointer — which frequently lands outside the view bounds; - the framework also emits a pointer-up for a finger that is still touching the screen.
Single touch is unaffected.
Code sample
Two files. Nothing else is needed — no plugins, no dependencies beyond a default
flutter create project.
The platform view is a plain Android View that only logs the MotionEvents it
receives and flags two anomalies computed from the raw event data:
- two pointers reporting identical integer coordinates — two real fingers cannot
occupy the same pixel; - a pointer whose coordinates fall outside the view bounds.
Steps
flutter runon a physical Android device (multi-touch required).- Press and hold on the left half, moving in circles.
- With that finger still down, press and hold on the right half.
- Release the left finger only.
- Press the left half again.
adb logcat -s MTRepro
Expected: pointer ids stay paired with their own finger's coordinates.
Actual: ★ lines appear from step 5 on.
android/app/src/main/kotlin/<...>/MainActivity.kt
package com.example.multitouch_repro // <-- match your package
import android.content.Context
import android.util.Log
import android.view.MotionEvent
import android.view.View
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.StandardMessageCodec
import io.flutter.plugin.platform.PlatformView
import io.flutter.plugin.platform.PlatformViewFactory
class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
flutterEngine.platformViewsController.registry
.registerViewFactory("touch_logger", TouchLoggerFactory())
}
}
class TouchLoggerFactory : PlatformViewFactory(StandardMessageCodec.INSTANCE) {
override fun create(context: Context, viewId: Int, args: Any?) =
object : PlatformView {
private val view = TouchLoggerView(context)
override fun getView(): View = view
override fun dispose() {}
}
}
class TouchLoggerView(context: Context) : View(context) {
override fun onTouchEvent(event: MotionEvent): Boolean {
val sb = StringBuilder()
val seen = HashMap<String, Int>()
var problem: String? = null
for (i in 0 until event.pointerCount) {
val id = event.getPointerId(i)
val x = event.getX(i)
val y = event.getY(i)
sb.append(" [i=$i id=$id ${x.toInt()},${y.toInt()}]")
val key = "${x.toInt()},${y.toInt()}"
val prev = seen.put(key, id)
if (prev != null && prev != id) {
problem = "id=$prev and id=$id report identical coordinates ($key)"
}
if (x < -2 || y < -2 || x > width + 2 || y > height + 2) {
problem = "id=$id at ${x.toInt()},${y.toInt()} is outside the view (${width}x$height)"
}
}
val name = when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> "DOWN"
MotionEvent.ACTION_UP -> "UP"
MotionEvent.ACTION_POINTER_DOWN -> "POINTER_DOWN(idx=${event.actionIndex})"
MotionEvent.ACTION_POINTER_UP -> "POINTER_UP(idx=${event.actionIndex})"
MotionEvent.ACTION_MOVE -> "MOVE"
MotionEvent.ACTION_CANCEL -> "CANCEL"
else -> "OTHER"
}
Log.i("MTRepro", "$name count=${event.pointerCount}$sb")
problem?.let { Log.e("MTRepro", " ★ $it") }
return true
}
}lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
void main() => runApp(const MaterialApp(home: Scaffold(body: TouchLogger())));
class TouchLogger extends StatelessWidget {
const TouchLogger({super.key});
@override
Widget build(BuildContext context) {
return PlatformViewLink(
viewType: 'touch_logger',
surfaceFactory: (context, controller) => AndroidViewSurface(
controller: controller as AndroidViewController,
hitTestBehavior: PlatformViewHitTestBehavior.opaque,
gestureRecognizers: const {},
),
onCreatePlatformView: (params) =>
PlatformViewsService.initExpensiveAndroidView(
id: params.id,
viewType: 'touch_logger',
layoutDirection: TextDirection.ltr,
)
..addOnPlatformViewCreatedListener(params.onPlatformViewCreated)
..create(),
);
}
}initExpensiveAndroidView (Hybrid Composition) is shown because that is what
flutter_inappwebview uses by default. initSurfaceAndroidView (TLHC/VD) and a
plain AndroidView reproduce the same way; so does
initHybridAndroidView (HCPP) with
io.flutter.embedding.android.EnableSurfaceControl enabled on Android 13+.
Adding an EagerGestureRecognizer makes no difference.
Evidence
Instrumented at three points — the framework's raw PointerEvents, a 1:1 replica of _AndroidMotionEventConverter showing what the framework should be sending, and the MotionEvent actually received by the Android view.
At the moment of corruption (device density 2.625; logical → physical):
[SYNTH] POINTER_DOWN(idx=1) count=2 [id=1 347,403] [id=0 188,360] <- framework side, correct
MTRepro POINTER_DOWN(idx=0 id=0) count=2 [i=0 id=0 912,1057] [i=1 id=1 1331,1169] <- as received, corrupted
i=0 id=0reports912,1057, which is347,403 × 2.625— the coordinates belonging to id=1.i=1 id=1reports1331,1169, which is2 × P(id=1) - P(id=0):
2×912 − 188×2.625 = 1330.5(observed 1331),2×1057 − 360×2.625 = 1169(observed 1169).
The reflection holds across 30+ samples to within 1px, e.g. P(id=0) = 60,323 → predicted 2×912 − 157.5 = 1666.5, observed 1666.
A control comparison from the same session, before the id is reused, is correct:
[SYNTH] POINTER_DOWN(idx=1) count=2 [id=0 66,378] [id=1 347,403]
MTRepro POINTER_DOWN(idx=1 id=1) count=2 [i=0 id=0 172,992] [i=1 id=1 912,1057]
(66,378 × 2.625 = 173,992 ≈ 172,992; 347,403 × 2.625 = 911,1058 ≈ 912,1057.)
Analysis
The framework builds pointerProperties and pointerCoords from pointerPositions.keys
(packages/flutter/lib/src/services/platform_views.dart, _AndroidMotionEventConverter.toAndroidMotionEvent), which is a LinkedHashMap in insertion order.
That order matches ascending Android pointer id order until a released id is reused: _remove() returns the id to usedAndroidPointerIds, and the next handlePointerDownEvent() assigns the lowest free id to a newly pressed pointer. After the sequence above, insertion order is [B, A] — ids [1, 0].
Engine side, PlatformViewsController.toMotionEvent() pops the original event from motionEventTracker and translates its coordinates rather than assigning them per pointer. The translation delta is taken positionally from the framework arrays, so a mismatched order produces a delta of P_other − P_real applied to every pointer — yielding exactly the two symptoms above.
So the two sides disagree about what the array order means: the framework emits insertion order, the engine reads it as ascending-id order.
Validation
Three-way A/B in a single build, switchable at runtime, dispatching to the same platform view:
| Dispatch | Result |
|---|---|
Framework (dispatchPointerEvent) |
reproduces |
Manual via AndroidViewController.sendMotionEvent, same ordering as the framework |
reproduces |
| Manual, arrays sorted by Android pointer id | does not reproduce; dragging feels normal |
The middle row is the control: it establishes that the manual channel is equivalent to the framework's, so the third row's result is attributable to the ordering change alone.
Proposed fix
Sort the pointer list by Android pointer id so both parallel arrays and the derived action index match what the engine assumes (patch attached, flutter-fix.patch):
final List<int> pointers = pointerPositions.keys.toList()
..sort((int a, int b) => pointerProperties[a]!.id.compareTo(pointerProperties[b]!.id));This makes the caller satisfy the engine's expectation. It does not remove the engine's positional assumption; a complementary engine-side fix would pair coordinates by pointer id instead of by array position. Happy to send either as a PR.
One thing worth a maintainer's eye: pointerIdx is also used by the batching guard
isSinglePointerAction(event) && pointerIdx < numPointers - 1, so sorting changes which pointer counts as "last". No dropped moves were observed in testing, but that testing was two-finger and manual — 3+ finger behaviour has not been stress-tested, and this converter has had multi-touch trouble there before (#69431).
Related
Previously reported as #157463 (closed while still labelled "in triage" / "waiting for response", no linked fix). That reporter traced it to translateMotionEvent and reported that Flutter 3.22.0 behaves correctly, i.e. this is a regression — we have not independently verified the 3.22.0 claim.
Real-world impact: an app embedding third-party HTML5/Godot games in a WebView. Every game using an on-screen joystick together with an action button is broken on Android; the same page in mobile Chrome behaves correctly.
Screenshots or Video
Screenshots / Video demonstration
[Upload media here]
Logs
Logs
[Paste your logs here]Flutter Doctor output
[!] Flutter (Channel [user-branch], 3.47.0, on macOS 26.5.2 25F84 darwin-arm64, locale zh-Hans-CN)
• Upstream repository https://github.com/flutter/flutter.git
• Framework revision 4cf2416426 (3 days ago), 2026-08-11 11:53:49 -0700
• Engine revision 5f77625673
• Dart version 3.13.0
• DevTools version 2.60.0
[!] Android toolchain - develop for Android devices (Android SDK version 35.0.0)
• Android SDK at ~/Library/Android/sdk
• Platform android-36, build-tools 35.0.0
• Java version OpenJDK Runtime Environment (build 21.0.8+-14196175-b1038.72)
[✓] Xcode - develop for iOS and macOS (Xcode 26.5)
[✓] Connected device (6 available)
• 23113RKC6C (mobile) • android-arm64 • Android 16 (API 36)
Reproduced on a physical Android device (MIUI), minSdkVersion 24 / targetSdk 35 / compileSdk 36,
in both initExpensiveAndroidView (HC) and initSurfaceAndroidView (TLHC/VD) modes, and in
initHybridAndroidView (HCPP) with io.flutter.embedding.android.EnableSurfaceControl enabled.
Adding an EagerGestureRecognizer makes no difference.
Activity
saurabh-mirajkar commentedon Aug 14, 2026
@wizardlyc Thanks for the detailed report. I see no code sample in the report, and the provided flutter doctor output is from Flutter 3.35.7 which is quite old. Could you please verify the attached reproduction on the latest stable Flutter 3.47.0 and, if it still reproduces, provide the minimal reproduction/code sample used for the test?
wizardlyc commentedon Aug 14, 2026
Code added,I test on 3.47.0, the bug is still there
saurabh-mirajkar commentedon Aug 14, 2026
@wizardlyc Thanks for adding the code sample and confirming that this is still reproducible on latest stable Flutter 3.47.0.
I was able to reproduce the issue on the latest
stableas well, using a Pixel 6 (Android 17) with the provided code sample.Additionally, I found a related older report #96640, which similarly involves incorrect multi-touch gestures being delivered to Android Platform Views.
Keeping this issue open for the team to evaluate further.
Fix pointer id/coordinate mismatch in Android platform view multi-touch
Fix pointer id/coordinate mismatch in Android platform view multi-touch
Fix pointer id/coordinate mismatch in Android platform view multi-tou…
github-actions commentedon Oct 14, 2026
This thread has been automatically locked since there has not been any recent activity after it was closed. If you are still experiencing a similar issue, please open a new bug, including the output of
flutter doctor -vand a minimal reproduction of the issue.Fix pointer id/coordinate mismatch in Android platform view multi-tou…