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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ frames after the inner ones; no frame waits for all of them.

### Added

- `onCameraMove` and `cameraMoveThrottleMs`: an opt-in, throttled stream of the camera
while it moves, for overlays that follow the map. Nothing runs unless it is set.
- `react-native-better-maps/reanimated` with `useCameraSharedValue`, which feeds that
stream into a Reanimated shared value; `react-native-reanimated` is an optional peer
dependency.
- `pinStyle` prop (`'flat' | 'system'`) for the Apple provider.
- `MarkerCollection` and `useMarkerCollection`: a native-owned marker dataset updated
through `set`, `upsert`, `remove` and `updatePositions`, passed to `MapView` with the
Expand Down
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,40 @@ function ControlledMap() {
}
```

### Following the camera

`onRegionChange` and `onRegionChangeComplete` fire once per gesture, which is what data loading wants. An overlay that must track the camera while it moves opts into a throttled stream:

```tsx
<MapView
onCameraMove={(camera) => setHeading(camera.heading ?? 0)}
cameraMoveThrottleMs={100}
/>
```

Nothing runs unless `onCameraMove` is set, and each call crosses to the JS thread, so pair a low throttle with a cheap handler. With Reanimated installed, `react-native-better-maps/reanimated` feeds the stream into a shared value that overlays read on the UI thread without a React render per update:

```tsx
import Animated, { useAnimatedStyle } from 'react-native-reanimated';
import { useCameraSharedValue } from 'react-native-better-maps/reanimated';

function MapWithCompass() {
const { camera, onCameraMove } = useCameraSharedValue();
const needle = useAnimatedStyle(() => ({
transform: [{ rotate: `${-(camera.value?.heading ?? 0)}deg` }],
}));

return (
<>
<MapView style={{ flex: 1 }} onCameraMove={onCameraMove} cameraMoveThrottleMs={16} />
<Animated.Text style={[styles.needle, needle]}>▲</Animated.Text>
</>
);
}
```

`react-native-reanimated` is an optional peer dependency; the main entry point does not import it.

## Map providers

`MapView` accepts an optional `provider` prop:
Expand Down Expand Up @@ -627,6 +661,7 @@ setMarkers((current) =>
| Scale control | Supported | Unsupported | Unsupported |
| Markers / overlays | Supported | Supported | Supported |
| Marker collections (deltas) | Supported | Supported | Supported |
| Camera stream (`onCameraMove`) | Supported | Supported | Supported |
| Pin style | `flat` (default) or `system` | Google default marker | Google default marker |
| Custom marker images | Supported | Supported | Supported |
| Marker callouts / dragging | Supported | Supported | Supported |
Expand Down Expand Up @@ -658,6 +693,7 @@ setMarkers((current) =>
| --------------------- | ------------------------------------------------------------------------ |
| `MarkerCollection` | Native-owned marker dataset updated through `set` / `upsert` / `remove` / `updatePositions` |
| `useMarkerCollection` | Creates one `MarkerCollection` for the lifetime of a component |
| `useCameraSharedValue` | From `react-native-better-maps/reanimated`: feeds `onCameraMove` into a Reanimated shared value |

### Types

Expand Down
6 changes: 6 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

62 changes: 62 additions & 0 deletions docs/adr/0007-camera-stream-and-cpp-core.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# ADR 0007: Camera stream, Reanimated binding, and the shared C++ core

## Status

Accepted

## Context

The performance audit's last phase listed three optional items: an opt-in stream of the
camera while it moves, a Reanimated binding for overlays that follow the map, and a shared
C++ core for the marker store, index and clustering, the last one only "if profiling after
phase 3 shows Kotlin or Swift compute as the limiter".

The camera reaches JS twice per gesture (`onRegionChange`, `onRegionChangeComplete`),
which is right for data loading and wrong for a compass or a custom overlay that must track
the map: those had to poll `getCamera()`, a three-hop promise per call.

## Decision

- **`onCameraMove` and `cameraMoveThrottleMs`.** While the camera moves the adapter emits
the camera at most every `cameraMoveThrottleMs` (default 100 ms) and once more when it
stops. MapKit samples the camera on a display link that runs only during the move; the
Google SDKs already report every frame and the adapter throttles. Nothing runs unless the
callback is set, so the idle map stays at zero work and the default map stays out of the
per-frame JS path.
- **`react-native-better-maps/reanimated`.** A separate entry point with
`useCameraSharedValue`, which returns a shared value and a stable `onCameraMove` handler
that writes into it. Overlays read the value in `useAnimatedStyle` and follow the camera on
the UI thread without a React render per update. `react-native-reanimated` is an optional
peer dependency; the main entry point does not import it.
- **Shared C++ core: not built.** The audit made it conditional on profiling showing
Swift or Kotlin compute as the limiter after the frame-budgeted pipeline. The signposts
from the 100,000-marker clustered scenario on the iPhone simulator put the whole compute
side (index query, clustering, diff) on the background queue at a p95 of 6.5 ms and a
maximum of 10 ms, and the main-thread apply at a maximum of 3.6 ms; the scenario that
still drops frames (10,000 markers inside one city viewport) spends up to 15 ms on the main
thread inside MapKit's annotation-view layout while its compute stays under 3.1 ms.
On the Android emulator the same 100,000-marker scenario holds a 17 ms p99 and a 33 ms worst frame, so Kotlin compute is not limiting frames there either. A C++ core would speed up the part that is
already off the main thread and already under a frame, and would leave the SDK view work
where it is. The store, index and cluster engine keep their two native implementations,
which share the packed batch format and the same test fixtures. The decision is revisited
if a future dataset or a device shows the background compute reaching the frame budget.

## Consequences

- A low throttle is a per-frame JS call. The Reanimated binding keeps the handler to one
assignment, which is the cheap end of what a per-frame call can do; a handler that sets
React state at 16 ms would re-render at 60 Hz.
- The stream reports the camera the SDK reports. On MapKit that is `MKMapView.camera` at
the display link's tick; during an animated camera change it follows the animation.
- Two entry points means two type roots in `lib/typescript`; `react-native-builder-bob`
compiles the whole `src` tree, so nothing changes in the build.

## Alternatives considered

- **A per-frame native binding to Reanimated's worklet runtime.** Would move the camera
into a shared value without touching the JS thread at all, at the cost of coupling the
native code to Reanimated's internal runtime API, which changes between major versions.
The JS-side binding costs one assignment per update and works with any Reanimated 3 or 4.
- **Reporting region instead of camera.** Region is the SDK's own derivation and diverges
between MapKit and Google when the map is tilted or rotated; overlays want heading, pitch
and zoom, which only the camera carries.
3 changes: 3 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ Map and overlay callbacks are wired through Nitro listeners on the HybridView. C
| Callback | Payload | Notes |
| ------------------------------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `onRegionChange` / `onRegionChangeComplete` | `Region` | iOS uses `MKCoordinateRegion` (center + span); Android derives center + deltas from visible `LatLngBounds`. Values agree without rotation/pitch but may diverge when the map is tilted or rotated. |
| `onCameraMove` | `Camera` | Opt-in stream while the camera moves, at most every `cameraMoveThrottleMs` (default 100) and once more when it stops. MapKit samples it on a display link; the Google SDKs report per frame and the adapter throttles. Nothing runs unless the callback is set. |
| `onPress` / `onLongPress` | `Coordinate` | Map background only; marker taps do not also fire map `onPress`. |
| `onPoiPress` | `PoiPressEvent` | Provider-owned base-map POIs only. Apple Maps emits category data; Google Maps emits place ID. POI taps do not also fire map `onPress`. |
| `onMapReady` | none | Fires once after the map finishes loading tiles. |
Expand Down Expand Up @@ -112,6 +113,8 @@ Markers take a different route, because their datasets are large and change ofte

The diff does not reach the map SDK in one pass. A per-map scheduler driven by `CADisplayLink` on iOS and `Choreographer` on Android applies removals at once, then a bounded number of adds per frame, nearest to the camera first, then retained updates within a 2 ms budget; the add count halves after a long frame and grows back on frames within budget. A newer diff replaces whatever is still pending, which is safe because diffs are computed against what is actually on the map. On MapKit the live refresh during gestures runs off the same display link instead of a wall-clock timer, and image-less markers are flat pre-rendered pins unless `pinStyle="system"` asks for `MKMarkerAnnotationView`. Clustering keeps the buckets of the cells that were fully inside the previous padded viewport for as long as the zoom octave and the dataset stay the same, so a pan only accumulates the cells that entered. See [ADR 0006](adr/0006-frame-budgeted-rendering.md).

The camera reaches JS through two events per gesture, `onRegionChange` when it begins and `onRegionChangeComplete` when it ends, which suits data loading. Overlays that must track the map while it moves opt into `onCameraMove`: MapKit samples `MKMapView.camera` on a display link that runs only between `regionWillChange` and `regionDidChange`, the Google SDKs report the camera every frame and the adapter throttles it to `cameraMoveThrottleMs`, and every adapter emits the final camera once the move ends. The `react-native-better-maps/reanimated` entry point turns that stream into a Reanimated shared value so overlays follow the camera on the UI thread without a React render per update. See [ADR 0007](adr/0007-camera-stream-and-cpp-core.md).

Marker and marker-cluster entering animations follow the same descriptor model. The public API accepts `false`, `system`, or a serializable preset config; the React wrapper normalizes that into native descriptors. Native provider adapters execute the animation when a marker render element appears in the render diff. Updating animation config for an already retained marker does not restart the animation; the new config is used the next time that marker is added again.

Google Maps SDKs are sensitive to marker animation churn. Large viewport refreshes can add many native marker instances on the main thread, so the Google provider limits how many markers animate per refresh and reveals the rest immediately. This keeps gestures responsive, but very large marker sets may still need clustering, disabled entering animations, or a future provider-specific animation strategy.
Expand Down
Loading
Loading