From b12d6e3fc00b71862c8276c78f7e4ef2b05ffbf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Sun, 19 Jul 2026 22:34:10 +0200 Subject: [PATCH] fix(android): MarkerView no longer blocks map pan/pinch (new arch) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Android with Fabric, a MarkerView captured the whole touch stream, so a pan or pinch that started on a marker never reached the map (#4255). This was a side effect of the touch fix in #4176, which called requestDisallowInterceptTouchEvent(true) on every ACTION_DOWN to keep child Pressables tappable — that also prevented the map from ever seeing the gesture (and, for pinch, the second finger's ACTION_POINTER_DOWN). Decide gesture ownership at touch-down based on what is under the finger: - Touch on a scrollable descendant (ScrollView/FlatList/ViewPager, detected via canScroll*): disallow map interception so the child keeps the gesture. The map is an ancestor and would otherwise steal the drag on its own touch slop before the child could claim it. - Otherwise: leave interception alone. Taps still reach child Pressables (Mapbox uses its own touch slop, so a tap is not intercepted) while a pan/pinch that merely starts on the marker moves the map. A tap that lands on a MarkerView is absorbed by the marker rather than falling through to touchable sources (symbol/shape layers) or pins underneath it. pointerEvents="none"/"box-none" markers stay transparent as before. Children that handle drags purely in JS (PanResponder, e.g. some sliders) never claim the native gesture and can be taken over by the map. For those, add a `stopGesturePropagation` prop: when set, the marker keeps every gesture. It is Android-only (iOS gesture recognisers already coexist with marker content). --- .../components/annotation/RNMBXMarkerView.kt | 35 ++++++ .../annotation/RNMBXMarkerViewContent.kt | 60 ++++++++++- .../annotation/RNMBXMarkerViewManager.kt | 5 + .../rnmbx/components/mapview/RNMBXMapView.kt | 23 ++++ docs/MarkerView.md | 18 ++++ docs/docs.json | 7 ++ .../src/examples/Annotations/MarkerView.tsx | 100 +++++++++++++++++- src/components/MarkerView.tsx | 15 +++ src/specs/RNMBXMarkerViewNativeComponent.ts | 1 + 9 files changed, 257 insertions(+), 7 deletions(-) diff --git a/android/src/main/java/com/rnmapbox/rnmbx/components/annotation/RNMBXMarkerView.kt b/android/src/main/java/com/rnmapbox/rnmbx/components/annotation/RNMBXMarkerView.kt index 4736083426..9e7153b05d 100644 --- a/android/src/main/java/com/rnmapbox/rnmbx/components/annotation/RNMBXMarkerView.kt +++ b/android/src/main/java/com/rnmapbox/rnmbx/components/annotation/RNMBXMarkerView.kt @@ -35,6 +35,7 @@ class RNMBXMarkerView(context: Context?, private val mManager: RNMBXMarkerViewMa private var mAllowOverlapWithPuck = false private var mIsSelected = false private var mPointerEvents: PointerEvents = PointerEvents.AUTO + private var mStopGesturePropagation = false fun setCoordinate(point: Point?) { mCoordinate = point @@ -70,9 +71,15 @@ class RNMBXMarkerView(context: Context?, private val mManager: RNMBXMarkerViewMa (mView as? RNMBXMarkerViewContent)?.setExternalPointerEvents(pointerEvents) } + fun setStopGesturePropagation(stop: Boolean) { + mStopGesturePropagation = stop + (mView as? RNMBXMarkerViewContent)?.setStopGesturePropagation(stop) + } + override fun addView(childView: View, childPosition: Int) { mView = childView (childView as? RNMBXMarkerViewContent)?.setExternalPointerEvents(mPointerEvents) + (childView as? RNMBXMarkerViewContent)?.setStopGesturePropagation(mStopGesturePropagation) // Note: Do not call this method on `super`. The view is added manually. } @@ -180,6 +187,34 @@ class RNMBXMarkerView(context: Context?, private val mManager: RNMBXMarkerViewMa // endregion + // region Hit testing + + /** + * Whether the given absolute screen coordinate (physical pixels, as from + * `View.getLocationOnScreen`) falls within this marker's on-screen content view. Used by the + * map's tap handling to stop a tap on a marker from selecting features/pins underneath it. + * Returns false for `pointerEvents="none"`/`"box-none"` markers, whose container is meant to + * be transparent to touches (taps on box-none children never reach the map anyway). + */ + fun containsScreenPoint(screenX: Float, screenY: Float): Boolean { + if (!didAddToMap || + mPointerEvents == PointerEvents.NONE || + mPointerEvents == PointerEvents.BOX_NONE + ) { + return false + } + val view = mView ?: return false + if (view.visibility != View.VISIBLE || view.width == 0 || view.height == 0) { + return false + } + val loc = IntArray(2) + view.getLocationOnScreen(loc) + return screenX >= loc[0] && screenX <= loc[0] + view.width && + screenY >= loc[1] && screenY <= loc[1] + view.height + } + + // endregion + // region Helper functions private fun getOptions(): ViewAnnotationOptions { diff --git a/android/src/main/java/com/rnmapbox/rnmbx/components/annotation/RNMBXMarkerViewContent.kt b/android/src/main/java/com/rnmapbox/rnmbx/components/annotation/RNMBXMarkerViewContent.kt index 29a9ad3b6c..d9bb2529cd 100644 --- a/android/src/main/java/com/rnmapbox/rnmbx/components/annotation/RNMBXMarkerViewContent.kt +++ b/android/src/main/java/com/rnmapbox/rnmbx/components/annotation/RNMBXMarkerViewContent.kt @@ -2,6 +2,7 @@ package com.rnmapbox.rnmbx.components.annotation import android.content.Context import android.view.MotionEvent +import android.view.View import android.view.View.MeasureSpec import android.view.ViewGroup import com.facebook.react.bridge.Arguments @@ -14,11 +15,16 @@ import com.rnmapbox.rnmbx.components.camera.BaseEvent class RNMBXMarkerViewContent(context: Context): ReactViewGroup(context) { var inAdd: Boolean = false private var externalPointerEvents: PointerEvents = PointerEvents.AUTO + private var stopGesturePropagation: Boolean = false fun setExternalPointerEvents(pointerEvents: PointerEvents) { externalPointerEvents = pointerEvents } + fun setStopGesturePropagation(stop: Boolean) { + stopGesturePropagation = stop + } + // Track last reported translation to avoid feedback loop: // Mapbox sets setTranslationX(512) → we fire event → JS sets transform:[{translateX:512}] // → Fabric calls setTranslationX(512) again → same value → no re-fire. @@ -38,16 +44,60 @@ class RNMBXMarkerViewContent(context: Context): ReactViewGroup(context) { if (externalPointerEvents == PointerEvents.NONE) { return false } - // On ACTION_DOWN, tell the parent MapView not to intercept subsequent MOVE/UP - // events for pan/zoom recognition — that would send CANCEL to child Pressables - // and suppress onPress. Android resets the disallow flag on each new DOWN, so - // calling this once per gesture is sufficient. See maplibre-react-native#1289. - if (ev.actionMasked == MotionEvent.ACTION_DOWN) { + // Decide, at the start of each gesture, who owns it: + // + // - If the touch lands on a scrollable descendant (ScrollView, FlatList, ViewPager, …), + // ask the map not to intercept so the child keeps the whole gesture and can scroll. + // The map is an ancestor, so its onInterceptTouchEvent runs before the child's — without + // this the map would steal the drag on its own touch slop before the child could claim it. + // - Otherwise we leave interception alone. Taps still reach child Pressables (Mapbox uses + // its own touch slop, so a tap is not intercepted), while a pan/pinch that merely starts + // on the marker reaches the map — instead of the marker being a dead zone (issue #4255). + // + // Note: scrollable detection cannot help children that handle drags purely in JS + // (PanResponder, e.g. some sliders); they never claim the native gesture, so the map takes + // over once the drag passes its slop. For those, set `stopGesturePropagation` on the + // MarkerView to keep every gesture inside the marker. Android resets the disallow flag on + // each new ACTION_DOWN. + if (ev.actionMasked == MotionEvent.ACTION_DOWN && + (stopGesturePropagation || touchStartsOnScrollableChild(ev.rawX, ev.rawY)) + ) { parent?.requestDisallowInterceptTouchEvent(true) } return super.dispatchTouchEvent(ev) } + private fun touchStartsOnScrollableChild(rawX: Float, rawY: Float): Boolean = + anyScrollableContains(this, rawX, rawY) + + private fun anyScrollableContains(view: View, rawX: Float, rawY: Float): Boolean { + if (view.visibility != View.VISIBLE) { + return false + } + val loc = IntArray(2) + view.getLocationOnScreen(loc) + val inside = rawX >= loc[0] && rawX <= loc[0] + view.width && + rawY >= loc[1] && rawY <= loc[1] + view.height + if (!inside) { + return false + } + if (view !== this && view.isScrollable()) { + return true + } + if (view is ViewGroup) { + for (i in 0 until view.childCount) { + if (anyScrollableContains(view.getChildAt(i), rawX, rawY)) { + return true + } + } + } + return false + } + + private fun View.isScrollable(): Boolean = + canScrollHorizontally(1) || canScrollHorizontally(-1) || + canScrollVertically(1) || canScrollVertically(-1) + override fun setTranslationX(translationX: Float) { super.setTranslationX(translationX) maybeFireAnnotationPositionEvent() diff --git a/android/src/main/java/com/rnmapbox/rnmbx/components/annotation/RNMBXMarkerViewManager.kt b/android/src/main/java/com/rnmapbox/rnmbx/components/annotation/RNMBXMarkerViewManager.kt index c54c7af417..485c90a8fa 100644 --- a/android/src/main/java/com/rnmapbox/rnmbx/components/annotation/RNMBXMarkerViewManager.kt +++ b/android/src/main/java/com/rnmapbox/rnmbx/components/annotation/RNMBXMarkerViewManager.kt @@ -75,6 +75,11 @@ class RNMBXMarkerViewManager(reactApplicationContext: ReactApplicationContext) : markerView.setIsSelected(isSelected.asBoolean()) } + @ReactProp(name = "stopGesturePropagation") + override fun setStopGesturePropagation(markerView: RNMBXMarkerView, value: Dynamic) { + markerView.setStopGesturePropagation(!value.isNull && value.asBoolean()) + } + override fun updateProperties(viewToUpdate: RNMBXMarkerView, props: ReactStylesDiffMap) { super.updateProperties(viewToUpdate, props) // The codegen delegate does not forward the standard `pointerEvents` ViewProp — it falls diff --git a/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapView.kt b/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapView.kt index 9043e02562..ce02ddb4e8 100644 --- a/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapView.kt +++ b/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapView.kt @@ -725,6 +725,12 @@ open class RNMBXMapView(private val mContext: Context, var mManager: RNMBXMapVie } } val screenPointPx = mMap?.pixelForCoordinate(point) + // A tap that reaches the map but lands within a MarkerView (e.g. on a non-interactive + // part of it) should be absorbed by the marker, not fall through to touchable sources + // (symbol/shape layers) or pins underneath it. See #4255 discussion. + if (screenPointPx != null && isPointOnMarkerView(screenPointPx)) { + return true + } val touchableSources = allTouchableSources val hits = HashMap?>() if (screenPointPx != null) { @@ -752,6 +758,23 @@ open class RNMBXMapView(private val mContext: Context, var mManager: RNMBXMapVie return false } + /** + * Whether the given map-surface coordinate (physical pixels, from `pixelForCoordinate`) lands + * within any `MarkerView` currently on the map. Converts to absolute screen coordinates so it + * can be compared against each marker view's `getLocationOnScreen` bounds. + */ + private fun isPointOnMarkerView(screenPointPx: ScreenCoordinate): Boolean { + val markerViews = mFeatures.mapNotNull { it.feature as? RNMBXMarkerView } + if (markerViews.isEmpty()) { + return false + } + val mapLoc = IntArray(2) + mapView.getLocationOnScreen(mapLoc) + val absX = mapLoc[0] + screenPointPx.x.toFloat() + val absY = mapLoc[1] + screenPointPx.y.toFloat() + return markerViews.any { it.containsScreenPoint(absX, absY) } + } + override fun onMapLongClick(point: Point): Boolean { val _this = this if (pointAnnotationCoordinators.any { it.getAndClearAnnotationDragged() }) { diff --git a/docs/MarkerView.md b/docs/MarkerView.md index b676aa7aa1..64887c1ce7 100644 --- a/docs/MarkerView.md +++ b/docs/MarkerView.md @@ -80,6 +80,24 @@ FIX ME NO DESCRIPTION _defaults to:_ `false` +### stopGesturePropagation + +```tsx +boolean +``` +Keep pan/pinch/drag gestures that start on this marker inside the marker instead of +letting them reach the map. Enable this for markers whose children handle their own +drags in JavaScript (e.g. a PanResponder-based slider), which otherwise race the map's +pan/zoom recogniser and can be taken over by the map. By default the marker is smart: +taps and native scrollables are kept, but a pan/pinch that merely starts on the marker +moves the map. Defaults to false. + +@platform android — on iOS the map's gesture recognisers already coexist with marker +content, so this has no effect. + + _defaults to:_ `false` + + ### children ```tsx diff --git a/docs/docs.json b/docs/docs.json index b9e079a84e..e27e8cc675 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -5817,6 +5817,13 @@ "default": "false", "description": "FIX ME NO DESCRIPTION" }, + { + "name": "stopGesturePropagation", + "required": false, + "type": "boolean", + "default": "false", + "description": "Keep pan/pinch/drag gestures that start on this marker inside the marker instead of\nletting them reach the map. Enable this for markers whose children handle their own\ndrags in JavaScript (e.g. a PanResponder-based slider), which otherwise race the map's\npan/zoom recogniser and can be taken over by the map. By default the marker is smart:\ntaps and native scrollables are kept, but a pan/pinch that merely starts on the marker\nmoves the map. Defaults to false.\n\n@platform android — on iOS the map's gesture recognisers already coexist with marker\ncontent, so this has no effect." + }, { "name": "children", "required": true, diff --git a/example/src/examples/Annotations/MarkerView.tsx b/example/src/examples/Annotations/MarkerView.tsx index 3ac99b6a2f..b98c92626f 100644 --- a/example/src/examples/Annotations/MarkerView.tsx +++ b/example/src/examples/Annotations/MarkerView.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { Button, Pressable, + ScrollView, StyleSheet, Switch, Text, @@ -278,6 +279,9 @@ const ShowMarkerView = () => { React.useState(false); const [mapMoveCount, setMapMoveCount] = React.useState(0); + const [featurePressCount, setFeaturePressCount] = React.useState(0); + const [scrollCount, setScrollCount] = React.useState(0); + const [stopGesture, setStopGesture] = React.useState(false); const onPressMap = (e: GeoJSON.Feature) => { const geometry = e.geometry as GeoJSON.Point; @@ -288,6 +292,22 @@ const ShowMarkerView = () => { setMapMoveCount((c) => c + 1); }, []); + // A tappable circle sitting directly under the first MarkerView, to demonstrate that a tap on + // the marker does not fall through and select the feature beneath it (#4255). + const underMarkerShape = React.useMemo( + () => ({ + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: {}, + geometry: { type: 'Point', coordinates: pointList[0]! }, + }, + ], + }), + [pointList], + ); + return ( <>