Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
533cb7d
fix(ios): evaluate Google provider flag in podspec scope
piotr-graczyk-dev Sep 11, 2026
d76a8c9
feat: add live Fabric marker views with native projection
piotr-graczyk-dev Sep 11, 2026
7805bc9
fix(ios): guard optional Google marker visual implementation
piotr-graczyk-dev Sep 11, 2026
3085c91
fix: preserve marker gestures and native host lifecycle
piotr-graczyk-dev Sep 11, 2026
51d7908
test: add physical marker benchmarks and document performance limits
piotr-graczyk-dev Sep 11, 2026
08cacff
chore: sync test runner migration from main
piotr-graczyk-dev Sep 11, 2026
fc8fd56
fix: measure camera motion in seconds and retain control evidence
piotr-graczyk-dev Sep 11, 2026
410badc
test: record corrected moving-camera and JSX control results
piotr-graczyk-dev Sep 11, 2026
7bb0775
feat: cluster live JSX markers with custom cluster renderers
piotr-graczyk-dev Sep 11, 2026
6343832
fix: fit custom clusters to their actual member coordinates
piotr-graczyk-dev Sep 11, 2026
2a96412
docs: record custom cluster runtime validation and remaining performa…
piotr-graczyk-dev Sep 11, 2026
bbe7c74
fix(android): scope codegen sources and document emulator profiling
piotr-graczyk-dev Sep 25, 2026
9ce0e51
fix: align live marker Fabric measurements with native projection
piotr-graczyk-dev Sep 29, 2026
45c13c8
feat: draw markers with live JSX via MarkerView
piotr-graczyk-dev Oct 14, 2026
af1a259
docs(example): add a live JSX markers scenario
piotr-graczyk-dev Oct 14, 2026
fc8233c
docs: document MarkerView and accept ADR 0004
piotr-graczyk-dev Oct 14, 2026
d6f464e
refactor: rebuild live JSX markers on current main
piotr-graczyk-dev Oct 14, 2026
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
65 changes: 55 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ Built with [Nitro Modules](https://nitro.margelo.com/) for high-performance nati
- [Region change events](#region-change-events)
- [Native POI press events](#native-poi-press-events)
- [Custom marker images](#custom-marker-images)
- [Live JSX markers](#live-jsx-markers)
- [GeoJSON overlays](#geojson-overlays)
- [Google Maps setup](#google-maps-setup)
- [Marker entering animations](#marker-entering-animations)
Expand All @@ -58,7 +59,8 @@ Built with [Nitro Modules](https://nitro.margelo.com/) for high-performance nati
- **Native POI taps** - `onPoiPress` reports provider-owned places from Apple Maps and Google Maps without confusing them with app-owned markers.
- **Native POI details** - `applePoiDetailPresentation` opens MapKit's own place details (callout, sheet, or Open in Maps) on Apple Maps, iOS 18+.
- **Camera control** - Declarative region/camera props plus imperative camera helpers.
- **Marker clustering** - Native marker clustering for large point sets.
- **Live JSX markers** - `MarkerView` draws markers with interactive, animated React Native content, placed by native projection with no snapshots and no per-frame JS.
- **Marker clustering** - Native marker clustering for large point sets, with optional JSX cluster badges.
- **Native entering animations** - Configurable marker and cluster entrance animations.
- **Expo friendly** - Config plugin for Google Maps API keys and location permissions.
- **Tree-shakeable package** - ESM-only build with an explicit `exports` map.
Expand Down Expand Up @@ -670,7 +672,44 @@ Platform notes:
| `rotation` | `rotation` |
| `flat` | `flat` |
| `opacity` | `opacity` |
| Custom RN child views | Not supported (use bitmap `image`) |
| Custom RN child views | `MarkerView` children |

## Live JSX markers

`MarkerView` draws a marker with live React Native content - `Pressable`, state, Reanimated animations - on Apple Maps and Google Maps alike. The map moves it with its native projection on every camera frame: nothing is snapshotted and no JavaScript runs while the camera moves.

```tsx
<MapView
clusteringEnabled
renderCluster={(cluster) => (
<MarkerView
coordinate={cluster.coordinate}
width={52}
height={52}
anchor={{ x: 0.5, y: 0.5 }}
>
<Pressable onPress={() => cluster.onPress()} style={styles.cluster}>
<Text>{cluster.count}</Text>
</Pressable>
</MarkerView>
)}
>
{places.map((place) => (
<MarkerView
key={place.id}
coordinate={place.coordinate}
width={76}
height={40}
>
<Pressable onPress={() => select(place.id)} style={styles.bubble}>
<Text>{place.price} zł</Text>
</Pressable>
</MarkerView>
))}
</MapView>
```

Live markers share native clustering with `Marker`, and are mounted only while shown: a marker grouped into a cluster is unmounted, so keep persistent state in your app. Give each a fixed `width` and `height`, and animate `transform` and `opacity` inside it. For large static datasets, SDK-drawn `Marker` images remain the cheaper choice. See [Live JSX markers](docs/custom-marker-views.md) for the full contract.

## GeoJSON overlays

Expand Down Expand Up @@ -926,6 +965,7 @@ An optional overlay field set to `null` - the way JSON data usually says "no val
| Scale control | Supported | Unsupported | Unsupported; debug builds log a warning |
| Markers / overlays | Supported | Supported | Supported |
| Custom marker images | Supported | Supported | Supported |
| Live JSX markers | Supported | Supported; the map renders at most 60 FPS | Supported |
| Marker callouts / dragging | Supported | Supported | Supported |
| Overlay press events | Supported | Supported | Supported |
| GeoJSON overlays | Supported (JS conversion) | Supported (JS conversion) | Supported (JS conversion) |
Expand All @@ -941,14 +981,15 @@ An optional overlay field set to `null` - the way JSON data usually says "no val

### Components

| Component | Description |
| ---------- | --------------------------------- |
| `MapView` | Root map container |
| `Marker` | Point annotation |
| `Polyline` | Line overlay |
| `Polygon` | Filled area overlay |
| `Circle` | Circular area overlay |
| `Geojson` | GeoJSON FeatureCollection overlay |
| Component | Description |
| ------------ | --------------------------------- |
| `MapView` | Root map container |
| `Marker` | Point annotation |
| `MarkerView` | Marker drawn by live JSX |
| `Polyline` | Line overlay |
| `Polygon` | Filled area overlay |
| `Circle` | Circular area overlay |
| `Geojson` | GeoJSON FeatureCollection overlay |

### Types

Expand All @@ -971,6 +1012,9 @@ An optional overlay field set to `null` - the way JSON data usually says "no val
| `MapViewPropsForProvider` | Provider-specific `MapView` props |
| `MarkerDescriptor` | Bulk marker descriptor |
| `MarkerProps` | Props for `Marker` |
| `MarkerViewProps` | Props for `MarkerView` |
| `MarkerViewCluster` | Cluster passed to `renderCluster` |
| `RenderMarkerViewCluster` | Type of the `renderCluster` prop |
| `MarkerImage` | Resolved marker image descriptor |
| `MarkerAnchor` | Anchor point on marker image (0..1) |
| `MarkerPoint` | Point offset in dp |
Expand Down Expand Up @@ -1014,6 +1058,7 @@ See [example/.env.example](example/.env.example) for the supported environment v
- [Expo setup](docs/expo-setup.md)
- [Architecture](docs/architecture.md)
- [GeoJSON overlays](docs/geojson.md)
- [Live JSX markers](docs/custom-marker-views.md)
- [Roadmap](docs/roadmap.md)
- [Contributing](CONTRIBUTING.md)
- [Code of conduct](CODE_OF_CONDUCT.md)
Expand Down
161 changes: 30 additions & 131 deletions docs/adr/0004-custom-view-markers.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,146 +2,45 @@

## Status

Proposed
Accepted — live projected hosts (`MarkerView`).

## Context

Today markers are **serialized descriptors**, not React Native child views. `<Marker />`
returns `null`; its props are collected in `MapView.tsx` into `MarkerDescriptor[]` and
passed as a single prop on the `MapView` HybridView. Native code renders SDK objects
(`MKAnnotation` / `MKAnnotationView` on Apple, bitmap-backed `GMSMarker` on Google).
Appearance customization is limited to a **bitmap image** (`MarkerDescriptor.image`); any
`children` passed to `<Marker>` are ignored.
`Marker` elements are serialized descriptors drawn by the SDK: images, clustering, viewport culling, dragging and callouts, for thousands of points. Their `children` are never mounted.

`react-native-maps` supports `<Marker>{arbitraryJSX}</Marker>`, where children are real RN
views. This is a fundamentally different rendering model from the current bulk-descriptor
pipeline and is a common migration blocker.
Apps migrating from `react-native-maps` need markers drawn by arbitrary JSX: price bubbles, avatars, buttons, and animations inside them. The requirement is live content that stays interactive and animated, without costing the map its frame rate (60 FPS, 120 FPS on ProMotion where the provider renders that fast).

### Platform constraint (the crux)
The SDKs disagree on what a marker can be:

| Platform / SDK | Can a marker be a "live" view? |
| --- | --- |
| **Apple MapKit** | Yes — `MKAnnotationView` can host any `UIView` (live, interactive RN views) |
| **Google Maps (iOS + Android)** | No — the marker icon is a **bitmap only**; a view must be snapshotted to a bitmap and re-snapshotted on change |

`react-native-maps` handles this by hosting a live view on MapKit and snapshotting children
to a bitmap on Google Maps. Any solution here must accept the same asymmetry.
| SDK | Live view as a marker? |
| ------------------- | ----------------------------------------------------------------------------------------------------- |
| Apple MapKit | `MKAnnotationView` can hold live views, but MapKit owns its lifecycle, reuse and hierarchy. |
| Google Maps iOS | `GMSMarker.iconView` is snapshotted: no interaction, and animation means re-snapshotting every frame. |
| Google Maps Android | Bitmap icons only; `AdvancedMarker.iconView` is also rendered to a bitmap. |

## Decision

Add custom-view markers as a **separate, opt-in capability** alongside — not replacing —
the existing descriptor pipeline. These are two distinct contracts:

- `<Marker image=... />` — the high-throughput bulk path (hundreds/thousands of markers,
bitmaps, clustering, viewport culling). Unchanged.
- `<MarkerView coordinate=...>{JSX}</MarkerView>` — a new opt-in, heavier path for a
bounded number of custom-view markers.

Rendering follows **Option C (hybrid)**: live views where the SDK allows (MapKit),
bitmap snapshot where it does not (Google Maps on both iOS and Android). The public API is
identical across platforms; only the native implementation differs per backend.

Keeping these as separate types (rather than adding `children` + nullable fields to the
existing `Marker` descriptor) follows the API-design rule of splitting distinct workflows
into distinct types instead of overloading one object.

### Considered alternatives

- **Option A — snapshot only (JS/view-shot → existing `image` pipeline).** Fastest,
cross-platform, reuses clustering. But static: no live interactivity/animation, every
content change requires a re-snapshot. Adopted as the **Phase 1 MVP**, not the end state.
- **Option B — live native subviews everywhere.** Not possible on Google Maps (bitmap-only
icons); would require manually positioned overlay views with z-order/gesture problems.
- **Option C — hybrid (chosen).** Live on MapKit, snapshot on Google. Closest to
`react-native-maps` behavior with the least fighting against each SDK.

## Proposed API

### Nitro spec (new HybridView)

```typescript
// package/src/native/specs/MarkerView.nitro.ts
import type { HybridView, HybridViewMethods, HybridViewProps } from 'react-native-nitro-modules'
import type { Coordinate } from '../../types/coordinate'
import type { MarkerAnchor, MarkerPoint } from './overlays'

export interface MarkerViewProps extends HybridViewProps {
coordinate: Coordinate
anchor?: MarkerAnchor
centerOffset?: MarkerPoint
draggable?: boolean
/**
* Rendering strategy on backends that support live views (MapKit).
* Google Maps always snapshots (SDK limitation).
* @default 'auto'
*/
renderMode?: 'auto' | 'snapshot'
zIndex?: number
}

export interface MarkerViewMethods extends HybridViewMethods {
/** Force a re-snapshot on bitmap backends after child content changes. */
redraw(): Promise<void>
}

export type MarkerView = HybridView<MarkerViewProps, MarkerViewMethods>
```

Add a `MarkerView` autolinking entry to `nitro.json` (separate `HybridMarkerView`
implementation class on iOS and Android).

### React usage

```tsx
<MapView>
<MarkerView coordinate={{ latitude, longitude }} onPress={...}>
<View style={styles.bubble}>
<Text>Custom!</Text>
</View>
</MarkerView>
</MapView>
```

`<MarkerView>` is a real Nitro HybridView (`getHostComponent`) that renders its children
natively — unlike the null-rendering `<Marker>`.

## Implementation plan (phased)

### Phase 0 — quick win (independent)

Done: iOS Google applies `MarkerDescriptor` image, anchor, centerOffset, rotation, flat,
and opacity so `<Marker image>` is consistent across MapKit, iOS Google, and Android Google.

### Phase 1 — MVP custom views (Option A)

- Add `children` support that renders off-screen and snapshots to a bitmap (prefer a native
snapshot; `react-native-view-shot` optional), feeding the existing
`MarkerDescriptor.image` pipeline. Delivers working cross-platform custom markers quickly.

### Phase 2 — full `MarkerView` (Option C)

- New `MarkerView` Nitro HybridView hosting the RN child subtree natively.
- `MapView` native code detects mounted child `MarkerView`s in its native view hierarchy.
- **iOS Apple / MapKit:** embed the live hosted `UIView` in an `MKAnnotationView`; reuse
existing anchor/center-offset logic.
- **iOS Google + Android Google:** snapshot the hosted view to a bitmap
(`UIGraphicsImageRenderer` / `Canvas`+`Bitmap`) → `GMSMarker.icon` / `BitmapDescriptor`;
re-snapshot on `redraw()` or layout change. Reuse `MarkerIconFactory` (Android).
- Implement `prepareForRecycle` (state reset) and `memorySize` (bitmaps) on the hosts.
- Follow native code rules: `final` classes, one top-level type per file, converters in
their own extension files.

## Open items to verify before Phase 2

- Confirm, against **current** Nitrogen docs/source, the supported model for HybridView
`children` and for mounting child views into the parent map's native hierarchy (do not
rely on remembered API details).
Add `MarkerView`, a separate component, next to `Marker`.

Its JSX stays a Fabric subtree. The map mounts the host over its SDK view and moves it with the SDK's own projection on every camera callback, on the UI thread. The same mechanism serves all three providers.

- **No snapshots** and **no per-frame JavaScript**: a camera frame is one projection per mounted marker and a translation, pixel-snapped.
- **One pipeline:** a live marker is a `MarkerDescriptor` with `liveView: true`. Native clustering and viewport filtering place it like any marker. A registry takes live elements out of each diff before the SDK sees it and tells JS which hosts to mount. Clustered or culled markers are unmounted.
- **JSX clusters:** `renderCluster` replaces SDK badges with live `MarkerView`s.
- **Correct touches:** the shadow node reports the native translation (`getTransform()`), so `measure()` and `Pressable` see the marker where it is drawn. Taps on a marker do not reach the map, POIs or SDK markers beneath it; drags still pan the map unless the marker's content claims them.
- **Nitro views with children:** generated Nitro views take no children, so `scripts/patch-nitrogen-generated.mjs` routes Fabric mounts to Swift containers on iOS and makes the Android managers `ViewGroupManager`s. The script fails if the generated code changes shape.

## Alternatives

- **Snapshot JSX into the image pipeline.** Reuses everything, but freezes content: no interaction, and every animation frame is a capture, an encode and an SDK upload. In an iOS simulator experiment, the layer snapshots alone took about 10 ms for 50 markers and 37 ms for 200, against an 8.3 ms frame at 120 Hz.
- **SDK view markers** (`MKAnnotationView`, `iconView`). Live only on MapKit; snapshots on Google. Three behaviors behind one API.
- **Positions from JavaScript per frame.** A JS round trip and a React commit per camera frame cannot hold 120 FPS.

## Consequences

- The existing bulk descriptor + clustering path is untouched; custom views are additive.
- Behavior is asymmetric by necessity: live/interactive on MapKit, static bitmap on Google
Maps. `renderMode` and `redraw()` make the snapshot semantics explicit.
- Custom-view markers are intended for a bounded count; large marker sets should keep using
the descriptor/image path.
- Enables a smoother migration path from `react-native-maps` for view-backed markers.
- Live markers are screen-aligned and drawn above SDK annotations. They do not get SDK collision, pitch occlusion, dragging, callouts or ground-plane rotation; `Marker` keeps those.
- The library cannot make arbitrary JSX free: each mounted marker is a live view. Clustering and culling bound how many are mounted; layout animations inside markers cost what they cost anywhere.
- On Android the map draws on its own surface and reports a camera frame after drawing it, so markers can trail fast pans by about a frame.
- The Nitrogen patch must be revisited on Nitro upgrades; it fails the codegen step rather than shipping a broken view.

See [Live JSX markers](../custom-marker-views.md) for the API and its limits.
Loading
Loading