Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
}

Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String?, List<Feature?>?>()
if (screenPointPx != null) {
Expand Down Expand Up @@ -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() }) {
Expand Down
18 changes: 18 additions & 0 deletions docs/MarkerView.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
100 changes: 98 additions & 2 deletions example/src/examples/Annotations/MarkerView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React from 'react';
import {
Button,
Pressable,
ScrollView,
StyleSheet,
Switch,
Text,
Expand Down Expand Up @@ -278,6 +279,9 @@ const ShowMarkerView = () => {
React.useState<boolean>(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;
Expand All @@ -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<GeoJSON.FeatureCollection>(
() => ({
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: {},
geometry: { type: 'Point', coordinates: pointList[0]! },
},
],
}),
[pointList],
);

return (
<>
<Button
Expand All @@ -306,7 +326,19 @@ const ShowMarkerView = () => {
}
onPress={() => setPointerEventsNone((prev) => !prev)}
/>
<Mapbox.MapView onPress={onPressMap} onRegionDidChange={onRegionDidChange} style={styles.matchParent}>
<Button
title={
stopGesture
? 'stopGesturePropagation ON – slider marker keeps gestures'
: 'stopGesturePropagation OFF – slider drag may pan map'
}
onPress={() => setStopGesture((prev) => !prev)}
/>
<Mapbox.MapView
onPress={onPressMap}
onRegionDidChange={onRegionDidChange}
style={styles.matchParent}
>
<Mapbox.Camera
defaultSettings={{
zoomLevel: 16,
Expand All @@ -318,6 +350,17 @@ const ShowMarkerView = () => {
<AnnotationContent title={'this is a point annotation'} />
</Mapbox.PointAnnotation>

<Mapbox.ShapeSource
id="under-marker-src"
shape={underMarkerShape}
onPress={() => setFeaturePressCount((c) => c + 1)}
>
<Mapbox.CircleLayer
id="under-marker-circle"
style={{ circleRadius: 44, circleColor: 'rgba(255,0,0,0.35)' }}
/>
</Mapbox.ShapeSource>

<Mapbox.MarkerView
coordinate={pointList[0]!}
allowOverlapWithPuck={allowOverlapWithPuck}
Expand All @@ -326,10 +369,55 @@ const ShowMarkerView = () => {
<AnnotationContent title={'this is a marker view'} />
</Mapbox.MarkerView>

<Mapbox.MarkerView
coordinate={pointList[1]!}
anchor={{ x: 0.5, y: 1.3 }}
allowOverlap
>
<View
collapsable={false}
style={{
width: 180,
height: 54,
backgroundColor: 'white',
borderRadius: 8,
borderWidth: 1,
borderColor: '#333',
}}
>
<ScrollView
horizontal
onScroll={() => setScrollCount((c) => c + 1)}
scrollEventThrottle={16}
showsHorizontalScrollIndicator
>
{Array.from({ length: 12 }).map((_, i) => (
<View
key={i}
style={{
width: 50,
height: 50,
margin: 2,
borderRadius: 6,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: `hsl(${i * 30}, 70%, 60%)`,
}}
>
<Text style={{ color: 'white', fontWeight: 'bold' }}>
{i}
</Text>
</View>
))}
</ScrollView>
</View>
</Mapbox.MarkerView>

<Mapbox.MarkerView
coordinate={INITIAL_COORDINATES[2]!}
allowOverlap
allowOverlapWithPuck={allowOverlapWithPuck}
stopGesturePropagation={stopGesture}
>
<InteractiveMarkerContent />
</Mapbox.MarkerView>
Expand All @@ -349,6 +437,8 @@ const ShowMarkerView = () => {

<Bubble>
<Text>Map moved: {mapMoveCount} times</Text>
<Text>Feature (under marker) pressed: {featurePressCount} times</Text>
<Text>ScrollView scrolled: {scrollCount} events</Text>
<Text>Tap on map to add a point annotation</Text>
</Bubble>
</>
Expand All @@ -362,7 +452,13 @@ export default ShowMarkerView;
/** @type ExampleWithMetadata['metadata'] */
const metadata = {
title: 'Marker View',
tags: ['PointAnnotation', 'MarkerView', 'Slider', 'Interactive', 'pointerEvents'],
tags: [
'PointAnnotation',
'MarkerView',
'Slider',
'Interactive',
'pointerEvents',
],
docs: `
Shows marker view and point annotations, including an interactive marker with
sliders, switch, counter, text input, and pressable button to verify complex
Expand Down
Loading
Loading