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 d6b8fff82e..561eed1020 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 @@ -1,13 +1,24 @@ package com.rnmapbox.rnmbx.components.annotation import android.content.Context +import android.view.MotionEvent import android.view.View.MeasureSpec import android.view.ViewGroup +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.ReactContext +import com.facebook.react.uimanager.UIManagerHelper import com.facebook.react.views.view.ReactViewGroup +import com.rnmapbox.rnmbx.components.camera.BaseEvent class RNMBXMarkerViewContent(context: Context): ReactViewGroup(context) { var inAdd: Boolean = false + // 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. + private var lastReportedTx = Float.NaN + private var lastReportedTy = Float.NaN + init { allowRenderingOutside() } @@ -17,6 +28,50 @@ class RNMBXMarkerViewContent(context: Context): ReactViewGroup(context) { configureParentClipping() } + override fun dispatchTouchEvent(ev: MotionEvent): Boolean { + // 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) { + parent?.requestDisallowInterceptTouchEvent(true) + } + return super.dispatchTouchEvent(ev) + } + + override fun setTranslationX(translationX: Float) { + super.setTranslationX(translationX) + maybeFireAnnotationPositionEvent() + } + + override fun setTranslationY(translationY: Float) { + super.setTranslationY(translationY) + maybeFireAnnotationPositionEvent() + } + + private fun maybeFireAnnotationPositionEvent() { + val tx = translationX + val ty = translationY + // Dedup: skip if value unchanged (prevents feedback loop when Fabric + // re-applies the same transform prop back to setTranslationX/Y). + if (tx == lastReportedTx && ty == lastReportedTy) return + lastReportedTx = tx + lastReportedTy = ty + + val reactContext = context as? ReactContext ?: return + val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, id) ?: return + // Use getSurfaceId(view) — more reliable for Fabric than getSurfaceId(context) + val surfaceId = UIManagerHelper.getSurfaceId(this) + dispatcher.dispatchEvent( + BaseEvent(surfaceId, id, "topAnnotationPosition", + Arguments.createMap().apply { + putDouble("x", tx.toDouble()) + putDouble("y", ty.toDouble()) + }, + canCoalesce = true) + ) + } + private fun configureParentClipping() { val parent = parent if (parent is android.view.ViewGroup) { diff --git a/android/src/main/java/com/rnmapbox/rnmbx/components/annotation/RNMBXMarkerViewContentManager.kt b/android/src/main/java/com/rnmapbox/rnmbx/components/annotation/RNMBXMarkerViewContentManager.kt index bbf271fe88..64a7ffd5e1 100644 --- a/android/src/main/java/com/rnmapbox/rnmbx/components/annotation/RNMBXMarkerViewContentManager.kt +++ b/android/src/main/java/com/rnmapbox/rnmbx/components/annotation/RNMBXMarkerViewContentManager.kt @@ -2,13 +2,14 @@ package com.rnmapbox.rnmbx.components.annotation import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.uimanager.ThemedReactContext -import com.facebook.react.uimanager.ViewGroupManager import com.facebook.react.viewmanagers.RNMBXMarkerViewContentManagerInterface +import com.rnmapbox.rnmbx.components.AbstractEventEmitter class RNMBXMarkerViewContentManager(reactApplicationContext: ReactApplicationContext) : - ViewGroupManager(), + AbstractEventEmitter(reactApplicationContext), RNMBXMarkerViewContentManagerInterface { + override fun getName(): String { return REACT_CLASS } @@ -17,6 +18,10 @@ class RNMBXMarkerViewContentManager(reactApplicationContext: ReactApplicationCon return RNMBXMarkerViewContent(context) } + override fun customEvents(): Map { + return mapOf("topAnnotationPosition" to "onAnnotationPosition") + } + companion object { const val REACT_CLASS = "RNMBXMarkerViewContent" } 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 afa1ef5551..55dc04cdd7 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 @@ -96,8 +96,6 @@ class RNMBXMarkerViewManager(reactApplicationContext: ReactApplicationContext) : } } } - - }) } } diff --git a/docs/MarkerView.md b/docs/MarkerView.md index c81967d7ec..b676aa7aa1 100644 --- a/docs/MarkerView.md +++ b/docs/MarkerView.md @@ -17,8 +17,9 @@ component for a maximum of around 100 views displayed at one time. This is implemented with view annotations on [Android](https://docs.mapbox.com/android/maps/guides/annotations/view-annotations/) and [iOS](https://docs.mapbox.com/ios/maps/guides/annotations/view-annotations). -This component has no dedicated `onPress` method. Instead, you should handle gestures -with the React views passed in as `children`. +This component has no dedicated `onPress` method. Instead, handle gestures +with the React views passed in as `children` — Pressable, TouchableOpacity, +etc. all work including their visual feedback (opacity, scale, etc.). ## props @@ -85,7 +86,8 @@ FIX ME NO DESCRIPTION ReactReactElement ``` _required_ -One or more valid React Native views. +One or more valid React Native views. You can use Pressable, TouchableOpacity, +etc. directly as children — onPress and touch feedback work correctly. diff --git a/docs/docs.json b/docs/docs.json index 6e76d668e4..3ef281b5a0 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -5665,7 +5665,7 @@ "name": "MapView" }, "MarkerView": { - "description": "MarkerView represents an interactive React Native marker on the map.\n\nIf you have static views, consider using PointAnnotation or SymbolLayer to display\nan image, as they'll offer much better performance. Mapbox suggests using this\ncomponent for a maximum of around 100 views displayed at one time.\n\nThis is implemented with view annotations on [Android](https://docs.mapbox.com/android/maps/guides/annotations/view-annotations/)\nand [iOS](https://docs.mapbox.com/ios/maps/guides/annotations/view-annotations).\n\nThis component has no dedicated `onPress` method. Instead, you should handle gestures\nwith the React views passed in as `children`.", + "description": "MarkerView represents an interactive React Native marker on the map.\n\nIf you have static views, consider using PointAnnotation or SymbolLayer to display\nan image, as they'll offer much better performance. Mapbox suggests using this\ncomponent for a maximum of around 100 views displayed at one time.\n\nThis is implemented with view annotations on [Android](https://docs.mapbox.com/android/maps/guides/annotations/view-annotations/)\nand [iOS](https://docs.mapbox.com/ios/maps/guides/annotations/view-annotations).\n\nThis component has no dedicated `onPress` method. Instead, handle gestures\nwith the React views passed in as `children` — Pressable, TouchableOpacity,\netc. all work including their visual feedback (opacity, scale, etc.).", "displayName": "MarkerView", "methods": [], "props": [ @@ -5727,7 +5727,7 @@ "required": true, "type": "ReactReactElement", "default": "none", - "description": "One or more valid React Native views." + "description": "One or more valid React Native views. You can use Pressable, TouchableOpacity,\netc. directly as children — onPress and touch feedback work correctly." } ], "fileNameWithExt": "MarkerView.tsx", diff --git a/docs/examples.json b/docs/examples.json index 57fce7cc0c..935397ae8f 100644 --- a/docs/examples.json +++ b/docs/examples.json @@ -648,9 +648,11 @@ "title": "Marker View", "tags": [ "PointAnnotation", - "MarkerView" + "MarkerView", + "Slider", + "Interactive" ], - "docs": "\nShows marker view and point annotations\n" + "docs": "\nShows marker view and point annotations, including an interactive marker with\nsliders, switch, counter, text input, and pressable button to verify complex\ntouch interactions inside a MarkerView.\n" }, "fullPath": "example/src/examples/Annotations/MarkerView.tsx", "relPath": "Annotations/MarkerView.tsx", diff --git a/example/src/examples/Annotations/MarkerView.tsx b/example/src/examples/Annotations/MarkerView.tsx index 7b3e03ddf8..1ea6380b14 100644 --- a/example/src/examples/Annotations/MarkerView.tsx +++ b/example/src/examples/Annotations/MarkerView.tsx @@ -1,5 +1,15 @@ import React from 'react'; -import { Button, StyleSheet, View, Text, TouchableOpacity } from 'react-native'; +import { + Button, + Pressable, + StyleSheet, + Switch, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; +import { Slider } from '@rneui/base'; import Mapbox from '@rnmapbox/maps'; import Bubble from '../common/Bubble'; @@ -19,6 +29,115 @@ const styles = StyleSheet.create({ fontWeight: 'bold', }, matchParent: { flex: 1 }, + interactiveCard: { + backgroundColor: 'white', + borderRadius: 12, + padding: 12, + width: 200, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.25, + shadowRadius: 4, + elevation: 5, + borderWidth: 1, + borderColor: '#ddd', + }, + cardTitle: { + fontWeight: 'bold', + fontSize: 13, + marginBottom: 8, + color: '#333', + }, + sliderLabel: { + fontSize: 11, + color: '#666', + marginBottom: 2, + }, + sliderValue: { + fontSize: 11, + fontWeight: '600', + color: '#333', + textAlign: 'right', + }, + sliderRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 4, + }, + switchRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginVertical: 4, + }, + switchLabel: { + fontSize: 11, + color: '#666', + }, + counterRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + marginVertical: 6, + }, + counterButton: { + backgroundColor: '#4A90D9', + width: 30, + height: 30, + borderRadius: 15, + alignItems: 'center', + justifyContent: 'center', + }, + counterButtonText: { + color: 'white', + fontWeight: 'bold', + fontSize: 16, + }, + counterValue: { + fontSize: 16, + fontWeight: 'bold', + marginHorizontal: 16, + minWidth: 24, + textAlign: 'center', + }, + textInput: { + borderWidth: 1, + borderColor: '#ccc', + borderRadius: 6, + paddingHorizontal: 8, + paddingVertical: 4, + fontSize: 11, + marginTop: 4, + backgroundColor: '#fafafa', + }, + pressableButton: { + marginTop: 8, + backgroundColor: '#4A90D9', + borderRadius: 6, + paddingVertical: 6, + alignItems: 'center', + }, + pressableButtonPressed: { + backgroundColor: '#2E6DB4', + }, + pressableText: { + color: 'white', + fontWeight: '600', + fontSize: 12, + }, + colorPreview: { + height: 20, + borderRadius: 4, + marginTop: 4, + borderWidth: 1, + borderColor: '#ccc', + }, + divider: { + height: 1, + backgroundColor: '#eee', + marginVertical: 6, + }, }); const AnnotationContent = ({ title }: { title: string }) => ( @@ -29,9 +148,125 @@ const AnnotationContent = ({ title }: { title: string }) => ( ); + +/** + * An interactive MarkerView with slider, switch, counter, text input, and + * pressable button — useful for verifying that complex touch interactions + * work correctly inside a MarkerView. + */ +const InteractiveMarkerContent = () => { + const [sliderValue, setSliderValue] = React.useState(0.5); + const [opacity, setOpacity] = React.useState(1.0); + const [toggleOn, setToggleOn] = React.useState(false); + const [counter, setCounter] = React.useState(0); + const [note, setNote] = React.useState(''); + const [pressCount, setPressCount] = React.useState(0); + + const hue = Math.round(sliderValue * 360); + const bgColor = `hsla(${hue}, 70%, 55%, ${opacity})`; + + return ( + + Interactive Marker + + {/* Slider: Hue */} + + Hue + {hue}° + + + + {/* Slider: Opacity */} + + Opacity + {opacity.toFixed(2)} + + + + {/* Color preview */} + + + + + {/* Switch */} + + + Toggle: {toggleOn ? 'ON' : 'OFF'} + + + + + + + {/* Counter */} + + setCounter((c) => c - 1)} + > + + + {counter} + setCounter((c) => c + 1)} + > + + + + + + + + {/* Text input */} + Note + + + {/* Pressable button */} + [ + styles.pressableButton, + pressed && styles.pressableButtonPressed, + ]} + onPress={() => setPressCount((c) => c + 1)} + > + + Pressed {pressCount} time{pressCount !== 1 ? 's' : ''} + + + + ); +}; + const INITIAL_COORDINATES: [number, number][] = [ [-73.99155, 40.73581], [-73.99155, 40.73681], + [-73.98955, 40.73581], ]; const ShowMarkerView = () => { @@ -68,13 +303,21 @@ const ShowMarkerView = () => { - {pointList.slice(2).map((coordinate, index) => ( + + + + + {pointList.slice(3).map((coordinate, index) => (