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 473608342..9e7153b05 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 29a9ad3b6..d9bb2529c 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 c54c7af41..485c90a8f 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 9043e0256..ce02ddb4e 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 b676aa7aa..64887c1ce 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 b9e079a84..e27e8cc67 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 3ac99b6a2..b98c92626 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 ( <>