[Android] Map and marker presses wait for Google's double-tap timeout (295–709 ms finger-down to JS); dispatching at finger-up measures 21–22 ms
Summary
On Android every map press and marker press waits for Google Maps' own gesture recogniser before JS hears about it. onMapClick fires only after Google's ~300 ms double-tap timeout, and onMarkerClick (which also hit-tests against Google's offset image-marker region) was slower still on our devices. iOS markers have their own tap recogniser and report immediately, so the same app feels markedly slower on Android when switching between markers or tapping the map to dismiss a callout. This is the long-standing #833 / #3429 complaint, measured and with a fix.
Measured finger-down → JS onPress on the arm64 emulator (API 36), debug build, with an instrumented test that injects real pointer events and reads the JS event timestamps back:
| Tap | Before | After (patch below) |
|---|---|---|
| empty map | 295 ms | 21 ms |
| centre of a marker | 649 ms | 22 ms |
| dead corner of a marker's bitmap | 709 ms | 22 ms |
Pixel 9 Pro, debug build: 73–75 ms after the patch (JIT-bound). Samsung A13, debug build, before the patch: up to 1.8 s.
What the patch does
MapView.dispatchTouchEvent already feeds every touch to a GestureDetector before handing it to Google (that is where tapLocation comes from). The patch adds onSingleTapUp to that detector and dispatches the JS press from there, at finger-up, with the finger position. Google's own onMapClick / onMarkerClick for the same up are then suppressed by a per-gesture flag (set by the fast tap, cleared on the next ACTION_DOWN), so JS still receives exactly one press per finger. A long press or a drag that Google consumed is never also reported as a tap (onMapLongClick and the drag callbacks set gestureConsumed). The detector applies touch slop, cancels on a second pointer, does not fire after the long-press timeout, and is not called for the second up of a double tap, so double-tap-to-zoom still works; the only behaviour change is that the first up of a double tap is reported as a press, which is what iOS already does.
The fast path only carries a plain press (no marker id): our app resolves the pin in JS from the finger position, because Google's marker hit region is offset/oversized on custom image markers (see the companion issue on onMarkerClick reporting the marker's position instead of the touch). If upstream wants to keep marker-press + id semantics, the fast path could be an opt-in prop.
Fix
Full diff of android/src/main/java/com/rnmaps/maps/MapView.java as we run it against 1.29.0 (also contains the tapLocation and showInfoWindow hunks filed separately, and a bounds guard on removeFeatureAt):
diff --git a/android/src/main/java/com/rnmaps/maps/MapView.java b/android/src/main/java/com/rnmaps/maps/MapView.java
index def61f5d..0d323232 100644
--- a/android/src/main/java/com/rnmaps/maps/MapView.java
+++ b/android/src/main/java/com/rnmaps/maps/MapView.java
@@ -157,6 +157,20 @@ public class MapView extends com.google.android.gms.maps.MapView implements Goog
private ViewAttacherGroup attacherGroup;
private LatLng tapLocation;
+ // PATCH: the press is dispatched from the GestureDetector's
+ // onSingleTapUp — at finger-UP, before Google's recogniser. Per gesture:
+ // set there, cleared on the next ACTION_DOWN, so onMapClick/onMarkerClick
+ // for the SAME up (Google delivers them ~300ms later, after its double-tap
+ // timeout, or at once for a marker) skip their own press — however long
+ // the UI thread stalls in between — while a later gesture the detector
+ // rejected (drift, a brief second pointer) that Google still calls a
+ // click reaches JS through Google's path as before.
+ private boolean fastTapDispatched = false;
+ // Google's long-click / drag thresholds are its own and fixed, while the
+ // GestureDetector's are the user-adjustable ViewConfiguration values
+ // (accessibility can push long-press to ~1.5s). Once Google has consumed
+ // the gesture as a long click or a drag, the up is not a tap.
+ private boolean gestureConsumed = false;
private Float maxZoomLevel;
private Float minZoomLevel;
private Integer mapType;
@@ -285,6 +299,20 @@ public class MapView extends com.google.android.gms.maps.MapView implements Goog
onDoublePress(ev);
return false;
}
+
+ // PATCH: dispatch the press at finger-UP.
+ // Google's onMapClick fires only after its 300ms double-tap
+ // timeout and its onMarkerClick names the wrong pin (offset
+ // hit region); the app decides the pin in JS from the finger
+ // anyway, so the recogniser only added latency. The detector
+ // applies touch slop, cancels on a second pointer, does not
+ // fire after its long-press timeout, and is not called for
+ // the second up of a double tap.
+ @Override
+ public boolean onSingleTapUp(MotionEvent ev) {
+ dispatchFastTap(ev);
+ return false;
+ }
});
this.addOnLayoutChangeListener(new OnLayoutChangeListener() {
@@ -572,16 +600,28 @@ public class MapView extends com.google.android.gms.maps.MapView implements Goog
public boolean onMarkerClick(@NonNull Marker marker) {
MapMarker airMapMarker = getMarkerMap(marker);
- WritableMap eventData = makeClickEventData(marker.getPosition());
+ // PATCH: report the actual FINGER location (tapLocation,
+ // captured in dispatchTouchEvent) instead of the marker's position —
+ // exactly like polygon/polyline presses already do. Google's custom
+ // image-marker hit area is offset/oversized on Android, so the marker's
+ // own position is wrong for JS hit-testing; the finger location isn't.
+ LatLng pressLoc = tapLocation != null ? tapLocation : marker.getPosition();
+
+ // PATCH: the fast tap already delivered this up
+ // as a press (onSingleTapUp); a second press would re-run the
+ // app's selection (duplicate analytics, a hide→reveal flicker).
+ if (!fastTapOwnsThisUp()) {
+ WritableMap eventData = makeClickEventData(pressLoc);
eventData.putString("action", "marker-press");
eventData.putString("id", airMapMarker.getIdentifier());
airMapMarker.dispatchEvent(eventData, OnPressEvent::new);
- WritableMap mapEventData = makeClickEventData(marker.getPosition());
+ WritableMap mapEventData = makeClickEventData(pressLoc);
mapEventData.putString("action", "marker-press");
mapEventData.putString("id", airMapMarker.getIdentifier());
dispatchEvent(mapEventData, OnMarkerPressEvent::new);
+ }
handleMarkerSelection(airMapMarker);
@@ -592,7 +632,17 @@ public class MapView extends com.google.android.gms.maps.MapView implements Goog
if (view.moveOnMarkerPress) {
return false;
} else {
- marker.showInfoWindow();
+ // PATCH: only show an info window the marker actually
+ // has. Google draws the marker whose info window is showing ABOVE
+ // every other marker regardless of zIndex — and an EMPTY info
+ // window (no callout child, no title) still counts. So every
+ // tapped pin quietly rose over the app's selection overlay
+ // (zIndex 999999), which is why a "selected" pin looked unselected
+ // until the next tap elsewhere (measured on a Samsung A13).
+ String title = marker.getTitle();
+ if (airMapMarker.getCalloutView() != null || (title != null && !title.isEmpty())) {
+ marker.showInfoWindow();
+ }
return true;
}
}
@@ -646,9 +696,14 @@ public class MapView extends com.google.android.gms.maps.MapView implements Goog
map.setOnMapClickListener(new GoogleMap.OnMapClickListener() {
@Override
public void onMapClick(@NonNull LatLng point) {
- WritableMap event = makeClickEventData(point);
- event.putString("action", "press");
- dispatchEvent(event, OnPressEvent::new);
+ // PATCH: the fast tap (onSingleTapUp) already
+ // delivered this up as a press, ~300ms ago — Google waits for
+ // its double-tap timeout before this callback.
+ if (!fastTapOwnsThisUp()) {
+ WritableMap event = makeClickEventData(point);
+ event.putString("action", "press");
+ dispatchEvent(event, OnPressEvent::new);
+ }
handleMarkerSelection(null);
}
@@ -657,6 +712,7 @@ public class MapView extends com.google.android.gms.maps.MapView implements Goog
map.setOnMapLongClickListener(new GoogleMap.OnMapLongClickListener() {
@Override
public void onMapLongClick(@NonNull LatLng point) {
+ gestureConsumed = true; // PATCH: the up after this is not a tap
WritableMap event = makeClickEventData(point);
event.putString("action", "long-press");
dispatchEvent(event, OnLongPressEvent::new);
@@ -1321,6 +1377,9 @@ public class MapView extends com.google.android.gms.maps.MapView implements Goog
return;
}
} else {
+ if (index < 0 || index >= features.size()) {
+ return;
+ }
feature = features.remove(index);
}
if (feature instanceof MapMarker) {
@@ -1650,17 +1709,38 @@ public class MapView extends com.google.android.gms.maps.MapView implements Goog
return markerView.getInfoContents();
}
+ // PATCH: see onSingleTapUp. Runs before super.dispatchTouchEvent
+ // hands the UP to Google, so it always precedes Google's callbacks for it.
+ private void dispatchFastTap(MotionEvent ev) {
+ if (map == null || destroyed || gestureConsumed || tapLocation == null) {
+ return;
+ }
+ // tapLocation is this UP's pixel, projected once in dispatchTouchEvent.
+ WritableMap event = makeClickEventData(tapLocation);
+ event.putString("action", "press");
+ dispatchEvent(event, OnPressEvent::new);
+ fastTapDispatched = true;
+ }
+
+ private boolean fastTapOwnsThisUp() {
+ return fastTapDispatched;
+ }
+
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
- gestureDetector.onTouchEvent(ev);
+ int action = ev.getActionMasked();
+ if (action == MotionEvent.ACTION_DOWN) {
+ gestureConsumed = false;
+ fastTapDispatched = false;
+ }
int X = (int) ev.getX();
int Y = (int) ev.getY();
if (map != null) {
tapLocation = map.getProjection().fromScreenLocation(new Point(X, Y));
}
-
- int action = ev.getActionMasked();
+ // After tapLocation: onSingleTapUp reads it.
+ gestureDetector.onTouchEvent(ev);
switch (action) {
case (MotionEvent.ACTION_DOWN):
@@ -1678,6 +1758,7 @@ public class MapView extends com.google.android.gms.maps.MapView implements Goog
@Override
public void onMarkerDragStart(Marker marker) {
+ gestureConsumed = true; // PATCH: the up after this is not a tap
WritableMap event = makeClickEventData(marker.getPosition());
dispatchEvent(event, OnMarkerDragStartEvent::new);
React Native Maps Version
1.29.0 (the gesture path is unchanged on master, v1.29.2)
What platforms are you seeing the problem on?
Android
React Native Version
0.86 (Expo SDK 57), New Architecture
Device(s)
arm64 emulator (API 36), Pixel 9 Pro, Samsung A13
Additional information
The instrumented test (real pointers via Instrumentation.sendPointerSync, JS events read back from a file) also proves: one press per finger with Google's callbacks suppressed; a long press, a drag and the second up of a double tap dispatch no press; and mutation checks (fast path off → the press arrives as Google's late marker-press; suppression off → two presses). A full write-up will be at https://zackdesign.biz/which-pin-did-you-tap/ shortly.
Source: react-native-maps/react-native-maps