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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ frames after the inner ones; no frame waits for all of them.
stream into a Reanimated shared value; `react-native-reanimated` is an optional peer
dependency.
- `pinStyle` prop (`'flat' | 'system'`) for the Apple provider.
- `markerRendering` prop (`'views' | 'sprites'`) for the Apple provider. `sprites` draws the
displayed markers and cluster badges into map tiles through an `MKOverlayRenderer`
instead of one annotation view per marker; taps and callouts still work, draggable
markers stay views.
- `MarkerCollection` and `useMarkerCollection`: a native-owned marker dataset updated
through `set`, `upsert`, `remove` and `updatePositions`, passed to `MapView` with the
new `markerCollection` prop. Each call ships one packed batch that only carries what
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -619,6 +619,18 @@ Markers without an `image` are drawn by MapKit. By default they are `flat` pins:

The prop is accepted for the `apple` provider and the default provider on iOS; Google Maps draws its own default marker. With flat pins the `system` entering animation is a plain appearance; use `fade` or `fade-scale` for motion.

## Sprites on Apple Maps

Even flat pins are one `MKAnnotationView` each, and MapKit lays every one of them out on the main thread when the set on screen changes. `markerRendering="sprites"` takes the views out of the picture: the displayed markers and cluster badges are drawn into the map's tiles through an `MKOverlayRenderer`, one bitmap per tile, on MapKit's own threads.

```tsx
<MapView provider="apple" markerRendering="sprites" clusteringEnabled />
```

What stays the same: which markers are shown (the viewport filter and the clustering are the same pipeline), marker images, anchors, offsets, rotation and opacity, `onMarkerPress` and `onClusterPress`, and `getClusterMembers`. A tapped marker with a `title` or `subtitle` is promoted to a real annotation view while its callout is open, and draggable markers are always views. What changes: sprites do not run entering animations, `pinStyle` does not apply to them (they draw the flat pin), and during a pinch MapKit scales the tiles it has until it has drawn new ones, so pins grow or shrink for a moment, as every overlay renderer's content does.

Reach for it when a viewport holds hundreds of markers and the zoom sweeps drop frames; below that, views are fine and animate.

## Re-renders

Nitro compares view props by reference identity, so a prop rebuilt from unchanged data would still be re-serialized across JSI and re-applied to the native map. `MapView` guards against that on your behalf:
Expand Down Expand Up @@ -663,6 +675,7 @@ setMarkers((current) =>
| Marker collections (deltas) | Supported | Supported | Supported |
| Camera stream (`onCameraMove`) | Supported | Supported | Supported |
| Pin style | `flat` (default) or `system` | Google default marker | Google default marker |
| Marker rendering | `views` (default) or `sprites` | Views | Views |
| Custom marker images | Supported | Supported | Supported |
| Marker callouts / dragging | Supported | Supported | Supported |
| Overlay press events | Supported | Supported | Supported |
Expand Down Expand Up @@ -707,6 +720,7 @@ setMarkers((current) =>
| `MapType` | `'standard' \| 'satellite' \| 'hybrid' \| 'terrain'` |
| `MapProvider` | `'apple' \| 'google' \| 'openstreetmap' \| 'mapbox'` |
| `MarkerPinStyle` | `'flat' \| 'system'`, Apple MapKit pin rendering |
| `MarkerRendering` | `'views' \| 'sprites'`, Apple MapKit marker rendering path |
| `PoiPressEvent` | Provider-discriminated native POI press payload |
| `ApplePoiPressEvent` | Apple Maps POI payload with category |
| `GooglePoiPressEvent` | Google Maps POI payload with place ID |
Expand Down
62 changes: 62 additions & 0 deletions docs/adr/0008-mapkit-sprite-layer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# ADR 0008: MapKit sprite layer

## Status

Accepted

## Context

After the frame-budgeted pipeline (ADR 0006) the frames MapKit still drops come from
annotation views: at an octave crossing of a zoom sweep the set on screen changes
wholesale, and even with adds spread over frames and one image layer per pin, MapKit
lays every new `MKAnnotationView` out on the main thread. The signposts in ADR 0007 put
the compute side under a frame on the background queue while the main-thread apply of
scenario N reached 15 ms inside MapKit. The audit's answer was a bulk rendering path
through `MKOverlayRenderer`, left for later; this is it.

## Decision

- **`markerRendering="sprites"` on the Apple provider.** The displayed markers and cluster
badges are drawn into map tiles by `MarkerSpriteRenderer`, an `MKOverlayRenderer` on a
world-sized overlay above the labels. MapKit calls it per tile on its own threads and
composites the tiles on the GPU. What the main thread keeps is the sprite publish, a
sort and a snapshot swap that stays under 1.5 ms at 2,000 sprites; what it loses is the
annotation-view layout of every viewport change. The zoom sweeps and the 100,000-marker
scenario gain from that; a plain pan, which only touches edge tiles, measures the same as
views (see `docs/benchmarks.md`).
- **Same pipeline, different apply.** The store, index, viewport filter, clustering and
diffing are untouched; sprite mode changes only what the controller does with a diff.
Sprites are applied at once (a dictionary update, no frame budget needed) and published
as an immutable snapshot the renderer reads under a lock. Draggable markers, and the
marker whose callout is open, go through the annotation-view path and its scheduler.
- **Interaction stays.** Taps are hit-tested against the snapshot, topmost sprite first.
A marker without a title fires `onMarkerPress` directly; one with a title or subtitle is
promoted to a selected annotation view so MapKit shows its callout, and its sprite comes
back when the callout closes. Cluster taps fire `onClusterPress` and zoom to the cluster.
- **Opt-in, not automatic.** Sprites do not run entering animations, ignore `pinStyle`,
and scale with the tiles during a pinch until MapKit has drawn new ones. Switching paths
by visible count would make those differences appear and disappear mid-session, so the
app chooses once.

## Consequences

- One more rendering path on MapKit to keep in step with the view path: geometry
(`MapMarkerAnnotation.centerOffset`), the pin image and the cluster badge are shared
code, so a change to the look reaches both.
- The pinch artifact is MapKit's: every overlay renderer's content, including polylines,
scales with the tiles until the re-render lands. It is documented rather than worked
around; a screen-space sprite view would avoid it at the cost of per-frame main-thread
repositioning, which is the cost the sprite layer removes.
- Callout promotion adds a view for one marker at a time, which is the annotation model's
ordinary cost.

## Alternatives considered

- **A `CALayer` per sprite in a view above the map.** Cheap to add, but every layer has to
be repositioned on the main thread on every frame of a gesture, which is the class of
work MapKit already does for annotation views.
- **Automatic bulk mode above a visible-count threshold.** Rejected for the mid-session
behavior change described above.
- **Drawing every marker in a tile from the spatial index, skipping the viewport filter.**
Would draw all 10,000 pins of scenario N at once; the filter exists for legibility as much
as for cost, and keeping it makes sprite mode show exactly what view mode shows.
3 changes: 3 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ Map and overlay callbacks are wired through Nitro listeners on the HybridView. C
| `fitToCoordinates(coords, padding?, animated?)` | Imperative ref method; fits camera to a set of coordinates with optional padding. |
| `markerCollection` | A `MarkerCollection` owned by the app. Replaces `markers` and `<Marker>` children; updated through `set`, `upsert`, `remove` and `updatePositions`. |
| `pinStyle` | Apple MapKit only. `flat` (default) draws image-less markers as one pre-rendered image on an `MKAnnotationView`; `system` uses `MKMarkerAnnotationView`. |
| `markerRendering` | Apple MapKit only. `views` (default) is one annotation view per displayed marker; `sprites` draws the displayed markers and cluster badges into map tiles through `MarkerSpriteRenderer`, an `MKOverlayRenderer`. Draggable markers and the marker whose callout is open stay views. |
| `getClusterMembers(clusterId)` | Imperative ref method; resolves the marker ids inside a displayed cluster. |

### Platform gaps (Phase 8)
Expand All @@ -115,6 +116,8 @@ The diff does not reach the map SDK in one pass. A per-map scheduler driven by `

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).

With `markerRendering="sprites"` the MapKit controller keeps the same pipeline and diffs but applies the sprite part of each diff at once: a dictionary of sprites (coordinate, bitmap, size, offset, rotation, opacity) becomes an immutable snapshot that a world-sized `MKOverlay`'s renderer draws per tile on MapKit's threads, above the labels. A viewport change is then one snapshot swap and a background re-render instead of annotation-view layout on the main thread. Taps are hit-tested against the snapshot; a marker with a title or subtitle is promoted to a selected annotation view for its callout and demoted when the callout closes; draggable markers always take the view path through the frame scheduler. See [ADR 0008](adr/0008-mapkit-sprite-layer.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