From cedb3be3e2eb37f0cad2dfe54cb080808fda2206 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Mon, 16 Mar 2026 08:08:56 +0100 Subject: [PATCH 1/8] fix(android): MarkerView touch/press works correctly on new architecture (Fabric) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Android with the new React Native architecture, Mapbox positions view annotations via setTranslationX/Y — a render-layer transform that is invisible to Fabric's shadow tree. This caused UIManager.measure to return (0,0) for every MarkerView, so Pressable/TouchableOpacity hit-testing failed, onPress was never fired, and interactive children (sliders, switches, buttons) didn't respond to touches. Fix: - Override setTranslationX/Y in RNMBXMarkerViewContent to intercept Mapbox's position updates and fire a topAnnotationPosition event to JS. - Deduplicate events to prevent a feedback loop when Fabric re-applies the same transform prop back to setTranslationX/Y. - Override dispatchTouchEvent to call requestDisallowInterceptTouchEvent(true), preventing the parent MapView's pan/zoom gesture from stealing MOVE events and sending CANCEL to child Pressables. - In JS (MarkerView.tsx), receive the position event and apply the translation as a React transform on RNMBXMarkerView, keeping the Fabric shadow tree in sync with Mapbox's native positioning so hit regions are correct. - Register the event via AbstractEventEmitter in RNMBXMarkerViewContentManager. - Add an interactive example (sliders, switch, counter, TextInput, Pressable) to the MarkerView example to exercise complex interactivity. iOS is unaffected: Mapbox iOS positions annotations via frame assignment, which Fabric tracks correctly without any workaround. --- .../annotation/RNMBXMarkerViewContent.kt | 62 +++++ .../RNMBXMarkerViewContentManager.kt | 9 +- .../annotation/RNMBXMarkerViewManager.kt | 2 - .../src/examples/Annotations/MarkerView.tsx | 253 +++++++++++++++++- src/components/MarkerView.tsx | 171 +++++++----- .../RNMBXMarkerViewContentNativeComponent.ts | 14 +- 6 files changed, 434 insertions(+), 77 deletions(-) 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..05746bce79 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,41 @@ 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.bridge.WritableMap +import com.facebook.react.uimanager.UIManagerHelper +import com.facebook.react.uimanager.events.Event import com.facebook.react.views.view.ReactViewGroup +private class AnnotationPositionEvent( + surfaceId: Int, + viewTag: Int, + translateX: Float, + translateY: Float, +) : Event(surfaceId, viewTag) { + private val mData: WritableMap = Arguments.createMap().apply { + putDouble("x", translateX.toDouble()) + putDouble("y", translateY.toDouble()) + } + override fun getEventName() = "topAnnotationPosition" + // Allow coalescing so rapid position updates don't flood the JS queue + override fun canCoalesce() = true + override fun getEventData(): WritableMap = mData +} + 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 +45,40 @@ class RNMBXMarkerViewContent(context: Context): ReactViewGroup(context) { configureParentClipping() } + override fun dispatchTouchEvent(ev: MotionEvent): Boolean { + // Prevent the parent MapView from intercepting subsequent MOVE/UP events + // for its own pan/zoom gesture recognition, which would send CANCEL to + // the Pressable and suppress onPress. See maplibre/maplibre-react-native#1289. + 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(AnnotationPositionEvent(surfaceId, id, tx, ty)) + } + 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/example/src/examples/Annotations/MarkerView.tsx b/example/src/examples/Annotations/MarkerView.tsx index 7b3e03ddf8..e89a8ba0cb 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 = () => { @@ -74,7 +309,15 @@ const ShowMarkerView = () => { - {pointList.slice(2).map((coordinate, index) => ( + + + + + {pointList.slice(3).map((coordinate, index) => ( { - static defaultProps: Partial = { - anchor: { x: 0.5, y: 0.5 }, - allowOverlap: false, - allowOverlapWithPuck: false, - isSelected: false, - }; +const MarkerView = ({ + anchor = { x: 0.5, y: 0.5 }, + allowOverlap = false, + allowOverlapWithPuck = false, + isSelected = false, + coordinate, + style, + children, +}: Props) => { + // Stable ID for the legacy pre-v10 iOS PointAnnotation fallback below. + const compatIdRef = useRef(`MV-${++_nextMarkerViewId}`); + + // Android new-arch (Fabric) fix: UIManager.measure reads from the Fabric shadow + // tree, which doesn't include Mapbox's native setTranslationX/Y positioning. + // Strategy: intercept setTranslationX/Y on the native side (see + // RNMBXMarkerViewContent.kt), relay the values as an onAnnotationPosition event, + // then apply them as a React `transform` on RNMBXMarkerView so the shadow tree + // reflects the actual on-screen position. This makes + // Pressability._responderRegion correct and onPress / touch feedback work. + // + // Key details: + // • position:'absolute' on RNMBXMarkerView → all markers have Yoga pos (0,0) + // in MapView, so the only shadow-tree offset is the transform itself. + // • Transform goes on RNMBXMarkerView (not RNMBXMarkerViewContent) so Fabric + // never fights Mapbox's native positioning. + // • Divide by PixelRatio: Android translationX/Y is in device pixels; React + // transform expects logical pixels (points). + // + // useState is called unconditionally (hooks rules) before the MapboxV10 early-return. + const [annotationTranslate, setAnnotationTranslate] = useState<{ + x: number; + y: number; + } | null>(null); - _getCoordinate(coordinate: Position): string | undefined { - if (!coordinate) { - return undefined; - } - return toJSONString(makePoint(coordinate)); + // Legacy pre-v10 iOS fallback — MapboxV10 is always truthy on current SDK + // versions so this branch is dead code; it will be removed in a follow-up. + if (Platform.OS === 'ios' && !Mapbox.MapboxV10) { + return ; } - render() { - if ( - this.props.anchor.x < 0 || - this.props.anchor.y < 0 || - this.props.anchor.x > 1 || - this.props.anchor.y > 1 - ) { - console.warn( - `[MarkerView] Anchor with value (${this.props.anchor.x}, ${this.props.anchor.y}) should not be outside the range [(0, 0), (1, 1)]`, - ); - } - - const { anchor = { x: 0.5, y: 0.5 } } = this.props; - - return ( - { - e.stopPropagation(); - }} - > - { - return true; - }} - onTouchEnd={(e) => { - e.stopPropagation(); - }} - > - {this.props.children} - - + if (anchor.x < 0 || anchor.y < 0 || anchor.x > 1 || anchor.y > 1) { + console.warn( + `[MarkerView] Anchor with value (${anchor.x}, ${anchor.y}) should not be outside the range [(0, 0), (1, 1)]`, ); } -} + + return ( + { + e.stopPropagation(); + }} + > + , + ) => { + // translationX/Y from Android View are in device pixels; React + // transform expects logical pixels (points). Divide by PixelRatio. + const pr = PixelRatio.get(); + setAnnotationTranslate({ + x: e.nativeEvent.x / pr, + y: e.nativeEvent.y / pr, + }); + }} + > + {children} + + + ); +}; const RNMBXMarkerView = NativeMarkerViewComponent; diff --git a/src/specs/RNMBXMarkerViewContentNativeComponent.ts b/src/specs/RNMBXMarkerViewContentNativeComponent.ts index bdb9547181..baf23666d2 100644 --- a/src/specs/RNMBXMarkerViewContentNativeComponent.ts +++ b/src/specs/RNMBXMarkerViewContentNativeComponent.ts @@ -1,7 +1,19 @@ import type { HostComponent, ViewProps } from 'react-native'; import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent'; +// @ts-ignore - CI environment type resolution issue for CodegenTypes +import { DirectEventHandler, Float } from 'react-native/Libraries/Types/CodegenTypes'; -export interface NativeProps extends ViewProps {} +type OnAnnotationPositionEvent = { + x: Float; + y: Float; +}; + +export interface NativeProps extends ViewProps { + // Fired by native when Mapbox repositions the annotation via setTranslationX/Y. + // JS uses this to keep the Fabric shadow tree transform in sync so that + // UIManager.measure returns the correct on-screen position for Pressable hit-testing. + onAnnotationPosition?: DirectEventHandler; +} // @ts-ignore-error - Codegen requires single cast but TypeScript prefers double cast export default codegenNativeComponent( From d3023fef3b1c5dc834d884d6d2f449aaa534de4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Sun, 22 Mar 2026 09:27:19 +0100 Subject: [PATCH 2/8] refactor(markerView): simplify and fix type errors from review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Make anchor/allowOverlap/allowOverlapWithPuck/isSelected props optional (restores defaultProps behaviour lost in class→function conversion; fixes TypeScript errors in CustomCallout, Markers.tsx, and example) - Fix non-null assertions on positional array accesses in example - Remove dead legacy iOS pre-v10 PointAnnotation fallback and associated imports (NativeModules, Platform, PointAnnotation, _nextMarkerViewId, compatIdRef) — MapboxV10 is always truthy on supported SDK versions - Move PixelRatio.get() to module-level constant (device-constant value) - Add JS-side position dedup (mirrors Kotlin-side dedup) to avoid re-renders when native re-applies the same translation - Wrap onTouchEnd and onAnnotationPosition in useCallback for stable refs - Replace custom AnnotationPositionEvent class with existing BaseEvent from com.rnmapbox.rnmbx.components.camera to eliminate duplication - Guard requestDisallowInterceptTouchEvent to ACTION_DOWN only — Android resets the disallow flag on each new gesture, so subsequent MOVE/UP calls were redundant --- .../annotation/RNMBXMarkerViewContent.kt | 39 +++++------ .../src/examples/Annotations/MarkerView.tsx | 4 +- src/components/MarkerView.tsx | 68 ++++++++----------- 3 files changed, 48 insertions(+), 63 deletions(-) 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 05746bce79..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 @@ -6,26 +6,9 @@ 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.bridge.WritableMap import com.facebook.react.uimanager.UIManagerHelper -import com.facebook.react.uimanager.events.Event import com.facebook.react.views.view.ReactViewGroup - -private class AnnotationPositionEvent( - surfaceId: Int, - viewTag: Int, - translateX: Float, - translateY: Float, -) : Event(surfaceId, viewTag) { - private val mData: WritableMap = Arguments.createMap().apply { - putDouble("x", translateX.toDouble()) - putDouble("y", translateY.toDouble()) - } - override fun getEventName() = "topAnnotationPosition" - // Allow coalescing so rapid position updates don't flood the JS queue - override fun canCoalesce() = true - override fun getEventData(): WritableMap = mData -} +import com.rnmapbox.rnmbx.components.camera.BaseEvent class RNMBXMarkerViewContent(context: Context): ReactViewGroup(context) { var inAdd: Boolean = false @@ -46,10 +29,13 @@ class RNMBXMarkerViewContent(context: Context): ReactViewGroup(context) { } override fun dispatchTouchEvent(ev: MotionEvent): Boolean { - // Prevent the parent MapView from intercepting subsequent MOVE/UP events - // for its own pan/zoom gesture recognition, which would send CANCEL to - // the Pressable and suppress onPress. See maplibre/maplibre-react-native#1289. - parent?.requestDisallowInterceptTouchEvent(true) + // 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) } @@ -76,7 +62,14 @@ class RNMBXMarkerViewContent(context: Context): ReactViewGroup(context) { val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, id) ?: return // Use getSurfaceId(view) — more reliable for Fabric than getSurfaceId(context) val surfaceId = UIManagerHelper.getSurfaceId(this) - dispatcher.dispatchEvent(AnnotationPositionEvent(surfaceId, id, tx, ty)) + dispatcher.dispatchEvent( + BaseEvent(surfaceId, id, "topAnnotationPosition", + Arguments.createMap().apply { + putDouble("x", tx.toDouble()) + putDouble("y", ty.toDouble()) + }, + canCoalesce = true) + ) } private fun configureParentClipping() { diff --git a/example/src/examples/Annotations/MarkerView.tsx b/example/src/examples/Annotations/MarkerView.tsx index e89a8ba0cb..1ea6380b14 100644 --- a/example/src/examples/Annotations/MarkerView.tsx +++ b/example/src/examples/Annotations/MarkerView.tsx @@ -303,14 +303,14 @@ const ShowMarkerView = () => { diff --git a/src/components/MarkerView.tsx b/src/components/MarkerView.tsx index e12c027e43..52a8294450 100644 --- a/src/components/MarkerView.tsx +++ b/src/components/MarkerView.tsx @@ -1,8 +1,6 @@ -import React, { useRef, useState } from 'react'; +import React, { useCallback, useRef, useState } from 'react'; import { - NativeModules, PixelRatio, - Platform, type NativeSyntheticEvent, type ViewProps, } from 'react-native'; @@ -11,11 +9,8 @@ import RNMBXMakerViewContentComponent from '../specs/RNMBXMarkerViewContentNativ import NativeMarkerViewComponent from '../specs/RNMBXMarkerViewNativeComponent'; import { type Position } from '../types/Position'; -import PointAnnotation from './PointAnnotation'; - -const Mapbox = NativeModules.RNMBXModule; - -let _nextMarkerViewId = 0; +// Device pixel ratio is constant for the lifetime of the app. +const PIXEL_RATIO = PixelRatio.get(); type Props = ViewProps & { /** @@ -27,7 +22,7 @@ type Props = ViewProps & { * Any coordinate between (0, 0) and (1, 1), where (0, 0) is the top-left corner of * the view, and (1, 1) is the bottom-right corner. Defaults to the center at (0.5, 0.5). */ - anchor: { + anchor?: { x: number; y: number; }; @@ -36,15 +31,15 @@ type Props = ViewProps & { * Whether or not nearby markers on the map should all be displayed. If false, adjacent * markers will 'collapse' and only one will be shown. Defaults to false. */ - allowOverlap: boolean; + allowOverlap?: boolean; /** * Whether or not nearby markers on the map should be hidden if close to a * UserLocation puck. Defaults to false. */ - allowOverlapWithPuck: boolean; + allowOverlapWithPuck?: boolean; - isSelected: boolean; + isSelected?: boolean; /** * One or more valid React Native views. You can use Pressable, TouchableOpacity, @@ -76,9 +71,6 @@ const MarkerView = ({ style, children, }: Props) => { - // Stable ID for the legacy pre-v10 iOS PointAnnotation fallback below. - const compatIdRef = useRef(`MV-${++_nextMarkerViewId}`); - // Android new-arch (Fabric) fix: UIManager.measure reads from the Fabric shadow // tree, which doesn't include Mapbox's native setTranslationX/Y positioning. // Strategy: intercept setTranslationX/Y on the native side (see @@ -92,20 +84,32 @@ const MarkerView = ({ // in MapView, so the only shadow-tree offset is the transform itself. // • Transform goes on RNMBXMarkerView (not RNMBXMarkerViewContent) so Fabric // never fights Mapbox's native positioning. - // • Divide by PixelRatio: Android translationX/Y is in device pixels; React - // transform expects logical pixels (points). - // - // useState is called unconditionally (hooks rules) before the MapboxV10 early-return. + // • PIXEL_RATIO: Android translationX/Y is in device pixels; React transform + // expects logical pixels (points). const [annotationTranslate, setAnnotationTranslate] = useState<{ x: number; y: number; } | null>(null); - // Legacy pre-v10 iOS fallback — MapboxV10 is always truthy on current SDK - // versions so this branch is dead code; it will be removed in a follow-up. - if (Platform.OS === 'ios' && !Mapbox.MapboxV10) { - return ; - } + // Mirror of Kotlin-side dedup: skip setState when position hasn't changed so + // we don't trigger a re-render for no-op native position re-applications. + const lastTranslateRef = useRef<{ x: number; y: number } | null>(null); + + const handleTouchEnd = useCallback((e: { stopPropagation: () => void }) => { + e.stopPropagation(); + }, []); + + const handleAnnotationPosition = useCallback( + (e: NativeSyntheticEvent<{ x: number; y: number }>) => { + const x = e.nativeEvent.x / PIXEL_RATIO; + const y = e.nativeEvent.y / PIXEL_RATIO; + const last = lastTranslateRef.current; + if (last !== null && last.x === x && last.y === y) return; + lastTranslateRef.current = { x, y }; + setAnnotationTranslate({ x, y }); + }, + [], + ); if (anchor.x < 0 || anchor.y < 0 || anchor.x > 1 || anchor.y > 1) { console.warn( @@ -132,24 +136,12 @@ const MarkerView = ({ allowOverlap={allowOverlap} allowOverlapWithPuck={allowOverlapWithPuck} isSelected={isSelected} - onTouchEnd={(e) => { - e.stopPropagation(); - }} + onTouchEnd={handleTouchEnd} > , - ) => { - // translationX/Y from Android View are in device pixels; React - // transform expects logical pixels (points). Divide by PixelRatio. - const pr = PixelRatio.get(); - setAnnotationTranslate({ - x: e.nativeEvent.x / pr, - y: e.nativeEvent.y / pr, - }); - }} + onAnnotationPosition={handleAnnotationPosition} > {children} From a2acb43288b5d9364c86a12c7cec64cd6acb215e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Sun, 22 Mar 2026 09:37:55 +0100 Subject: [PATCH 3/8] perf(markerView): stabilise all inline prop values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract DEFAULT_ANCHOR constant — default object literal was recreating {x:0.5, y:0.5} on every render when no anchor was passed - Memoize nativeCoordinate with useMemo([coord[0], coord[1]]) — avoids a new array on every render when coordinate values haven't changed - Memoize nativeStyle with useMemo([style, annotationTranslate]) — avoids a new array (and inner object) every render - Move inline style objects to StyleSheet.create (absolutePosition, contentContainer) — stable references, processed once by the native layer --- src/components/MarkerView.tsx | 51 ++++++++++++++++++++++++----------- 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/src/components/MarkerView.tsx b/src/components/MarkerView.tsx index 52a8294450..aa6dc5de0a 100644 --- a/src/components/MarkerView.tsx +++ b/src/components/MarkerView.tsx @@ -1,6 +1,7 @@ -import React, { useCallback, useRef, useState } from 'react'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; import { PixelRatio, + StyleSheet, type NativeSyntheticEvent, type ViewProps, } from 'react-native'; @@ -12,6 +13,8 @@ import { type Position } from '../types/Position'; // Device pixel ratio is constant for the lifetime of the app. const PIXEL_RATIO = PixelRatio.get(); +const DEFAULT_ANCHOR = { x: 0.5, y: 0.5 }; + type Props = ViewProps & { /** * The center point (specified as a map coordinate) of the marker. @@ -63,7 +66,7 @@ type Props = ViewProps & { * etc. all work including their visual feedback (opacity, scale, etc.). */ const MarkerView = ({ - anchor = { x: 0.5, y: 0.5 }, + anchor = DEFAULT_ANCHOR, allowOverlap = false, allowOverlapWithPuck = false, isSelected = false, @@ -117,21 +120,32 @@ const MarkerView = ({ ); } + const nativeCoordinate = useMemo( + () => [Number(coordinate[0]), Number(coordinate[1])] as [number, number], + // eslint-disable-next-line react-hooks/exhaustive-deps + [coordinate[0], coordinate[1]], + ); + + const nativeStyle = useMemo( + () => [ + styles.absolutePosition, + style, + annotationTranslate != null + ? { + transform: [ + { translateX: annotationTranslate.x }, + { translateY: annotationTranslate.y }, + ], + } + : undefined, + ], + [style, annotationTranslate], + ); + return ( {children} @@ -151,4 +165,9 @@ const MarkerView = ({ const RNMBXMarkerView = NativeMarkerViewComponent; +const styles = StyleSheet.create({ + absolutePosition: { position: 'absolute' }, + contentContainer: { flex: 0, alignSelf: 'flex-start' }, +}); + export default MarkerView; From 6745760bf9e7be50d3ac0ac2ec162b0a582bbc0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Sun, 22 Mar 2026 09:39:57 +0100 Subject: [PATCH 4/8] style: format CodegenTypes import per prettier --- src/specs/RNMBXMarkerViewContentNativeComponent.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/specs/RNMBXMarkerViewContentNativeComponent.ts b/src/specs/RNMBXMarkerViewContentNativeComponent.ts index baf23666d2..fb96865e63 100644 --- a/src/specs/RNMBXMarkerViewContentNativeComponent.ts +++ b/src/specs/RNMBXMarkerViewContentNativeComponent.ts @@ -1,7 +1,10 @@ import type { HostComponent, ViewProps } from 'react-native'; import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent'; // @ts-ignore - CI environment type resolution issue for CodegenTypes -import { DirectEventHandler, Float } from 'react-native/Libraries/Types/CodegenTypes'; +import { + DirectEventHandler, + Float, +} from 'react-native/Libraries/Types/CodegenTypes'; type OnAnnotationPositionEvent = { x: Float; From ca831ad484366834ae9572c497494ecf3ca0868f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Sun, 22 Mar 2026 09:51:08 +0100 Subject: [PATCH 5/8] fix(specs): keep CodegenTypes import single-line so @ts-ignore suppresses CI typecheck error --- src/specs/RNMBXMarkerViewContentNativeComponent.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/specs/RNMBXMarkerViewContentNativeComponent.ts b/src/specs/RNMBXMarkerViewContentNativeComponent.ts index fb96865e63..9dc4010852 100644 --- a/src/specs/RNMBXMarkerViewContentNativeComponent.ts +++ b/src/specs/RNMBXMarkerViewContentNativeComponent.ts @@ -1,14 +1,11 @@ import type { HostComponent, ViewProps } from 'react-native'; import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent'; // @ts-ignore - CI environment type resolution issue for CodegenTypes -import { - DirectEventHandler, - Float, -} from 'react-native/Libraries/Types/CodegenTypes'; +import { DirectEventHandler } from 'react-native/Libraries/Types/CodegenTypes'; type OnAnnotationPositionEvent = { - x: Float; - y: Float; + x: number; + y: number; }; export interface NativeProps extends ViewProps { From c133209c786b912941cd623f1afe4a555cce319a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Sun, 22 Mar 2026 10:32:20 +0100 Subject: [PATCH 6/8] fix(codegen): restore Float type in OnAnnotationPositionEvent for RN codegen parser --- src/specs/RNMBXMarkerViewContentNativeComponent.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/specs/RNMBXMarkerViewContentNativeComponent.ts b/src/specs/RNMBXMarkerViewContentNativeComponent.ts index 9dc4010852..baf23666d2 100644 --- a/src/specs/RNMBXMarkerViewContentNativeComponent.ts +++ b/src/specs/RNMBXMarkerViewContentNativeComponent.ts @@ -1,11 +1,11 @@ import type { HostComponent, ViewProps } from 'react-native'; import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent'; // @ts-ignore - CI environment type resolution issue for CodegenTypes -import { DirectEventHandler } from 'react-native/Libraries/Types/CodegenTypes'; +import { DirectEventHandler, Float } from 'react-native/Libraries/Types/CodegenTypes'; type OnAnnotationPositionEvent = { - x: number; - y: number; + x: Float; + y: Float; }; export interface NativeProps extends ViewProps { From b213caf5b37519bd4156882b7eed139b784e24f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Sun, 22 Mar 2026 10:36:05 +0100 Subject: [PATCH 7/8] docs: regenerate MarkerView.md after JSDoc update --- docs/MarkerView.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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. From 3abc4bbbc1745bf0641038473736349512be6cb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Sun, 22 Mar 2026 10:42:04 +0100 Subject: [PATCH 8/8] docs: regenerate docs/examples.json after children prop description update --- docs/docs.json | 4 ++-- docs/examples.json | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) 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",