Skip to content

fix(markerview): track native marker position in the shadow tree instead of echoing it through JS - #4359

Merged
mfazekas merged 1 commit into
mainfrom
fix/markerview-native-translation
Oct 19, 2026
Merged

mfazekas merged 1 commit into
mainfrom
fix/markerview-native-translation

Conversation

@mfazekas

Copy link
Copy Markdown
Contributor

Description

Fixes #4193

#4176 made Pressable inside an Android MarkerView survive finger movement by sending every native position change to JS (onAnnotationPosition) and re-rendering a matching transform. That's a React render per marker per frame while the map moves, which is the jank in #4193.

This removes the echo. RNMBXMarkerViewContent gets a custom C++ shadow node whose getTransform() adds the marker's on-screen offset, read from a small native side table keyed by surface and tag. Android writes it through JNI when Mapbox sets translationX/Y; iOS writes it when Mapbox sets the annotation view's frame. Nothing is committed while the map moves, and measure/measureInWindow are current without a touch. The spec stays interfaceOnly; react-native.config.js registers the custom descriptor and points autolinking at android/src/main/jni/CMakeLists.txt, and the podspec compiles cpp/.

It also fixes iOS: a press with a slightly moving finger on a MarkerView was cancelled on main (0/3, the #3832 bug on iOS), and measureInWindow returned the Yoga position.

main this PR
Android janky frames, pan, 500 markers ~94% ~50%
Android janky frames, pan, 200 markers ~57% ~12%
Android janky frames, zoom, 500 markers 59-72% 25-32%
JS fps during zoom 50 63
Android tap with ~6dp move: rest / after pan / during camera animation 3/3 / 3/3 / 3/3 3/3 / 3/3 / 3/3
iOS tap with ~6pt move: rest / after camera move / during animation 0/3 3/3 / 3/3 / 3/3
iOS measureInWindow after camera move, no touch Yoga position matches getPointInView

Verified on RN 0.79.7 (Android, iOS, iOS static frameworks), the example app on RN 0.84, and Expo 54 and 57 installed from a packed tarball (descriptor registered and JNI symbols present in libappmodules.so).

Known limitations: values computed at commit time (IntersectionObserver, Android overflowInset) don't update every frame anymore. iOS MarkerView inside an RN Modal isn't covered (same as before). Second-finger taps and tapping a child that overflows the marker on iOS fail on main too and are left for separate fixes.

Checklist

  • I've read CONTRIBUTING.md
  • I updated the doc/other generated code with running yarn generate in the root folder
  • I have tested the new feature on /example app.
    • In V11 mode/ios
    • In New Architecture mode/ios
    • In V11 mode/android
    • In New Architecture mode/android
  • I added/updated a sample - if a new feature was implemented (/example)

Component to reproduce the issue you're fixing

Jank: render a few hundred markers and pan/zoom (Android, profileable release, dumpsys gfxinfo). Taps: press the marker while moving the finger ~6dp, after panning or during setCamera animations; measureInWindow should match getPointInView(coordinate).

const C = [19.04, 47.5];
const coords = Array.from({ length: 500 }, (_, i) => [C[0] - 0.05 + (i % 23) * 0.0043, C[1] - 0.05 + Math.floor(i / 23) * 0.0043]);

export default function Repro() {
  const map = useRef(null);
  const target = useRef(null);
  const [presses, setPresses] = useState(0);
  const check = async () => {
    const pt = await map.current.getPointInView(C);
    target.current.measureInWindow((x, y, w, h) => console.log('center', x + w / 2, y + h / 2, 'point', pt));
  };
  return (
    <View style={{ flex: 1 }}>
      <MapView ref={map} style={{ flex: 1 }} onMapIdle={check}>
        <Camera defaultSettings={{ centerCoordinate: C, zoomLevel: 11 }} />
        {coords.map((c, i) => (
          <MarkerView key={i} coordinate={c} allowOverlap>
            <View style={{ width: 12, height: 12, backgroundColor: 'teal' }} />
          </MarkerView>
        ))}
        <MarkerView coordinate={C} allowOverlap>
          <Pressable ref={target} onPress={() => setPresses((p) => p + 1)} style={({ pressed }) => ({ width: 60, height: 60, backgroundColor: pressed ? 'green' : 'orange' })} />
        </MarkerView>
      </MapView>
      <Text>presses: {presses}</Text>
    </View>
  );
}

…ead of echoing it through JS

Mapbox positions marker views natively (translationX/Y on Android, the
annotation view's frame on iOS), which the shadow tree doesn't see, so
measure()/measureInWindow() returned the Yoga position and Pressable cancelled
presses as soon as the finger moved (#3832). #4176 fixed this on Android by
sending every position change to JS and re-rendering a matching transform,
which costs a React render per marker per frame while the map moves (#4193).

RNMBXMarkerViewContent now has a custom C++ shadow node whose getTransform()
adds a per-marker offset read from a native side table
(RNMBXMarkerViewContentTranslations). Android writes it through JNI when
Mapbox sets translationX/Y, iOS from the annotation frame. Nothing is
committed while the map moves, and measure is current without a touch, which
also fixes move-taps and measureInWindow on iOS.

The spec stays interfaceOnly; react-native.config.js registers the custom
component descriptor and points autolinking at android/src/main/jni, and the
podspec compiles cpp/ on iOS.

Fixes #4193
@mfazekas
mfazekas merged commit 1790df1 into main Oct 19, 2026
9 checks passed
@mfazekas
mfazekas deleted the fix/markerview-native-translation branch October 19, 2026 03:59

This branch was successfully deployed

1 active deployment
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant