|
| 1 | +# ADR 0004: Custom view markers |
| 2 | + |
| 3 | +## Status |
| 4 | + |
| 5 | +Proposed |
| 6 | + |
| 7 | +## Context |
| 8 | + |
| 9 | +Today markers are **serialized descriptors**, not React Native child views. `<Marker />` |
| 10 | +returns `null`; its props are collected in `MapView.tsx` into `MarkerDescriptor[]` and |
| 11 | +passed as a single prop on the `MapView` HybridView. Native code renders SDK objects |
| 12 | +(`MKAnnotation` / `MKAnnotationView` on Apple, bitmap-backed `GMSMarker` on Google). |
| 13 | +Appearance customization is limited to a **bitmap image** (`MarkerDescriptor.image`); any |
| 14 | +`children` passed to `<Marker>` are ignored. |
| 15 | + |
| 16 | +`react-native-maps` supports `<Marker>{arbitraryJSX}</Marker>`, where children are real RN |
| 17 | +views. This is a fundamentally different rendering model from the current bulk-descriptor |
| 18 | +pipeline and is a common migration blocker. |
| 19 | + |
| 20 | +### Platform constraint (the crux) |
| 21 | + |
| 22 | +| Platform / SDK | Can a marker be a "live" view? | |
| 23 | +| --- | --- | |
| 24 | +| **Apple MapKit** | Yes — `MKAnnotationView` can host any `UIView` (live, interactive RN views) | |
| 25 | +| **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 | |
| 26 | + |
| 27 | +`react-native-maps` handles this by hosting a live view on MapKit and snapshotting children |
| 28 | +to a bitmap on Google Maps. Any solution here must accept the same asymmetry. |
| 29 | + |
| 30 | +## Decision |
| 31 | + |
| 32 | +Add custom-view markers as a **separate, opt-in capability** alongside — not replacing — |
| 33 | +the existing descriptor pipeline. These are two distinct contracts: |
| 34 | + |
| 35 | +- `<Marker image=... />` — the high-throughput bulk path (hundreds/thousands of markers, |
| 36 | + bitmaps, clustering, viewport culling). Unchanged. |
| 37 | +- `<MarkerView coordinate=...>{JSX}</MarkerView>` — a new opt-in, heavier path for a |
| 38 | + bounded number of custom-view markers. |
| 39 | + |
| 40 | +Rendering follows **Option C (hybrid)**: live views where the SDK allows (MapKit), |
| 41 | +bitmap snapshot where it does not (Google Maps on both iOS and Android). The public API is |
| 42 | +identical across platforms; only the native implementation differs per backend. |
| 43 | + |
| 44 | +Keeping these as separate types (rather than adding `children` + nullable fields to the |
| 45 | +existing `Marker` descriptor) follows the API-design rule of splitting distinct workflows |
| 46 | +into distinct types instead of overloading one object. |
| 47 | + |
| 48 | +### Considered alternatives |
| 49 | + |
| 50 | +- **Option A — snapshot only (JS/view-shot → existing `image` pipeline).** Fastest, |
| 51 | + cross-platform, reuses clustering. But static: no live interactivity/animation, every |
| 52 | + content change requires a re-snapshot. Adopted as the **Phase 1 MVP**, not the end state. |
| 53 | +- **Option B — live native subviews everywhere.** Not possible on Google Maps (bitmap-only |
| 54 | + icons); would require manually positioned overlay views with z-order/gesture problems. |
| 55 | +- **Option C — hybrid (chosen).** Live on MapKit, snapshot on Google. Closest to |
| 56 | + `react-native-maps` behavior with the least fighting against each SDK. |
| 57 | + |
| 58 | +## Proposed API |
| 59 | + |
| 60 | +### Nitro spec (new HybridView) |
| 61 | + |
| 62 | +```typescript |
| 63 | +// package/src/native/specs/MarkerView.nitro.ts |
| 64 | +import type { HybridView, HybridViewMethods, HybridViewProps } from 'react-native-nitro-modules' |
| 65 | +import type { Coordinate } from '../../types/coordinate' |
| 66 | +import type { MarkerAnchor, MarkerPoint } from './overlays' |
| 67 | + |
| 68 | +export interface MarkerViewProps extends HybridViewProps { |
| 69 | + coordinate: Coordinate |
| 70 | + anchor?: MarkerAnchor |
| 71 | + centerOffset?: MarkerPoint |
| 72 | + draggable?: boolean |
| 73 | + /** |
| 74 | + * Rendering strategy on backends that support live views (MapKit). |
| 75 | + * Google Maps always snapshots (SDK limitation). |
| 76 | + * @default 'auto' |
| 77 | + */ |
| 78 | + renderMode?: 'auto' | 'snapshot' |
| 79 | + zIndex?: number |
| 80 | +} |
| 81 | + |
| 82 | +export interface MarkerViewMethods extends HybridViewMethods { |
| 83 | + /** Force a re-snapshot on bitmap backends after child content changes. */ |
| 84 | + redraw(): Promise<void> |
| 85 | +} |
| 86 | + |
| 87 | +export type MarkerView = HybridView<MarkerViewProps, MarkerViewMethods> |
| 88 | +``` |
| 89 | +
|
| 90 | +Add a `MarkerView` autolinking entry to `nitro.json` (separate `HybridMarkerView` |
| 91 | +implementation class on iOS and Android). |
| 92 | +
|
| 93 | +### React usage |
| 94 | +
|
| 95 | +```tsx |
| 96 | +<MapView> |
| 97 | + <MarkerView coordinate={{ latitude, longitude }} onPress={...}> |
| 98 | + <View style={styles.bubble}> |
| 99 | + <Text>Custom!</Text> |
| 100 | + </View> |
| 101 | + </MarkerView> |
| 102 | +</MapView> |
| 103 | +``` |
| 104 | +
|
| 105 | +`<MarkerView>` is a real Nitro HybridView (`getHostComponent`) that renders its children |
| 106 | +natively — unlike the null-rendering `<Marker>`. |
| 107 | +
|
| 108 | +## Implementation plan (phased) |
| 109 | +
|
| 110 | +### Phase 0 — quick win (independent) |
| 111 | +
|
| 112 | +- Fix the existing gap where iOS Google (`GoogleMapOverlayController.updateMarker`) sets |
| 113 | + `icon = nil` and never applies `MarkerDescriptor.image`, so `<Marker image>` is |
| 114 | + consistent across all three backends (MapKit, iOS Google, Android Google). |
| 115 | +
|
| 116 | +### Phase 1 — MVP custom views (Option A) |
| 117 | +
|
| 118 | +- Add `children` support that renders off-screen and snapshots to a bitmap (prefer a native |
| 119 | + snapshot; `react-native-view-shot` optional), feeding the existing |
| 120 | + `MarkerDescriptor.image` pipeline. Delivers working cross-platform custom markers quickly. |
| 121 | +
|
| 122 | +### Phase 2 — full `MarkerView` (Option C) |
| 123 | +
|
| 124 | +- New `MarkerView` Nitro HybridView hosting the RN child subtree natively. |
| 125 | +- `MapView` native code detects mounted child `MarkerView`s in its native view hierarchy. |
| 126 | +- **iOS Apple / MapKit:** embed the live hosted `UIView` in an `MKAnnotationView`; reuse |
| 127 | + existing anchor/center-offset logic. |
| 128 | +- **iOS Google + Android Google:** snapshot the hosted view to a bitmap |
| 129 | + (`UIGraphicsImageRenderer` / `Canvas`+`Bitmap`) → `GMSMarker.icon` / `BitmapDescriptor`; |
| 130 | + re-snapshot on `redraw()` or layout change. Reuse `MarkerIconFactory` (Android). |
| 131 | +- Implement `prepareForRecycle` (state reset) and `memorySize` (bitmaps) on the hosts. |
| 132 | +- Follow native code rules: `final` classes, one top-level type per file, converters in |
| 133 | + their own extension files. |
| 134 | +
|
| 135 | +## Open items to verify before Phase 2 |
| 136 | +
|
| 137 | +- Confirm, against **current** Nitrogen docs/source, the supported model for HybridView |
| 138 | + `children` and for mounting child views into the parent map's native hierarchy (do not |
| 139 | + rely on remembered API details). |
| 140 | +
|
| 141 | +## Consequences |
| 142 | +
|
| 143 | +- The existing bulk descriptor + clustering path is untouched; custom views are additive. |
| 144 | +- Behavior is asymmetric by necessity: live/interactive on MapKit, static bitmap on Google |
| 145 | + Maps. `renderMode` and `redraw()` make the snapshot semantics explicit. |
| 146 | +- Custom-view markers are intended for a bounded count; large marker sets should keep using |
| 147 | + the descriptor/image path. |
| 148 | +- Enables a smoother migration path from `react-native-maps` for view-backed markers. |
0 commit comments