diff --git a/README.md b/README.md index ce833bfe..d9578105 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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. @@ -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 + ( + + cluster.onPress()} style={styles.cluster}> + {cluster.count} + + + )} +> + {places.map((place) => ( + + select(place.id)} style={styles.bubble}> + {place.price} zł + + + ))} + +``` + +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 @@ -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) | @@ -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 @@ -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 | @@ -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) diff --git a/docs/adr/0004-custom-view-markers.md b/docs/adr/0004-custom-view-markers.md index 37deb8b1..a8141414 100644 --- a/docs/adr/0004-custom-view-markers.md +++ b/docs/adr/0004-custom-view-markers.md @@ -2,146 +2,45 @@ ## Status -Proposed +Accepted — live projected hosts (`MarkerView`). ## Context -Today markers are **serialized descriptors**, not React Native child views. `` -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 `` 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 `{arbitraryJSX}`, 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: - -- `` — the high-throughput bulk path (hundreds/thousands of markers, - bitmaps, clustering, viewport culling). Unchanged. -- `{JSX}` — 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 -} - -export type MarkerView = HybridView -``` - -Add a `MarkerView` autolinking entry to `nitro.json` (separate `HybridMarkerView` -implementation class on iOS and Android). - -### React usage - -```tsx - - - - Custom! - - - -``` - -`` is a real Nitro HybridView (`getHostComponent`) that renders its children -natively — unlike the null-rendering ``. - -## Implementation plan (phased) - -### Phase 0 — quick win (independent) - -Done: iOS Google applies `MarkerDescriptor` image, anchor, centerOffset, rotation, flat, -and opacity so `` 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. diff --git a/docs/custom-marker-views.md b/docs/custom-marker-views.md new file mode 100644 index 00000000..41f3c110 --- /dev/null +++ b/docs/custom-marker-views.md @@ -0,0 +1,85 @@ +# Live JSX markers (`MarkerView`) + +`MarkerView` draws a marker with live React Native content: state, `Pressable`, Reanimated animations and anything else a view can hold. Its JSX stays in Fabric. The map moves it with its native projection on every camera frame: no bitmap snapshots, and no JavaScript while the camera moves. + +```tsx +import { MapView, MarkerView } from 'react-native-better-maps'; + + + {places.map((place) => ( + + select(place.id)} style={styles.bubble}> + {place.price} zł + + + ))} +; + +const renderCluster = (cluster: MarkerViewCluster) => ( + + cluster.onPress()} style={styles.cluster}> + {cluster.count} + + +); +``` + +The example app's **Live JSX markers** scenario shows 120 price bubbles with press feedback, Reanimated pulses and JSX cluster badges. + +## Props + +| Prop | Behavior | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `coordinate` | Where the anchor point goes. Changing it moves the marker and its cluster membership. | +| `width`, `height` | Fixed size in points / dp. It bounds layout, clipping and touches, and is what `anchor` refers to. Animate content inside it, not the marker's own size. | +| `anchor` | Point of the marker placed on `coordinate`, as fractions of its size. Default bottom-center, `{ x: 0.5, y: 1 }`. Values outside 0…1 offset the marker. | +| `id` | Identifies it in cluster membership and `onClusterPress`. `Marker` and `MarkerView` share one set of ids; it defaults to the React key, then to the position. | +| `clusterable` | Default true: joins clusters when `clusteringEnabled` is set. | +| `children` | Live React Native content. | + +Like every overlay, a `MarkerView` must be a direct child of `MapView` (arrays from `.map` are fine). Put your own component inside it, not around it. A marker with an invalid coordinate, size or anchor is skipped with a development warning, as other overlays are. + +## Clusters + +`MarkerView` and `Marker` children feed one native clustering pipeline and can share a cluster. Without `renderCluster`, clusters keep the SDK badge. With it, each cluster is drawn by the `MarkerView` it returns. The cluster's `coordinate`, `count` and `markerIds` come from native; its `onPress` calls `onClusterPress` and zooms to its markers, as a press on a default badge does. The map sets the returned marker's `coordinate`, and keeps its React state while the count changes. + +A live marker exists only while the pipeline shows it. Grouped into a cluster, or left far off screen in a dataset over 500 markers, it is **unmounted**, so hidden animated subtrees cost nothing. Keep selection and other state that has to survive that in your app, not in the marker. + +## Touches and gestures + +The marker owns touches that start on it: a tap reaches its `Pressable`, not the map's `onPress`, a POI or an SDK marker beneath it. A drag or pinch that starts on a marker still pans or zooms the map, unless a view inside the marker, such as a `ScrollView`, claims it first. + +`Pressable` stays pressed through small finger movements: the marker's shadow node reports its projected position, so `measure()` sees it where it is drawn. + +## How it stays fast + +- **Per camera frame** the native side projects each mounted marker once, snaps it to the pixel grid and sets a translation. One projection snapshot is reused for every marker. Nothing reaches JS; no Yoga layout runs. +- **Markers off screen** are hidden, which takes them out of drawing and hit-testing. +- **JS hears only display-set changes**, when markers cluster, uncluster or leave a large dataset's viewport, never per frame. A live marker that only moved does not trigger it either. +- **Content animations** run in Fabric like anywhere else. Animate `transform` and `opacity` (Reanimated runs those on the UI thread); animating `width`, `height` or text re-runs layout every frame and costs as much as it does outside a map. + +Live markers are drawn over the map surface, in children order, above SDK annotations. They are screen-aligned: they do not take part in SDK collision, pitch occlusion, dragging, callouts or flat ground-plane rotation. Use `Marker` for those, and for large static datasets, where an SDK image is much cheaper than a live view. + +On Android, Google Maps renders on its own surface and reports each camera frame after drawing it, so during fast pans live markers can trail the map by about a frame. Google Maps on iOS renders at most 60 FPS, whatever the display refresh rate. + +## Implementation + +| Piece | Role | +| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `MarkerView.nitro.ts` | Nitro view with `coordinate` and `anchor`. Fabric lays it out at the map's origin with the fixed size. | +| `NitroMapContainerView` (iOS, Android) | The map's Fabric view. Mounts hosts over the SDK view and moves them on every camera callback. | +| `NitroMarkerContentView` (iOS, Android) | A marker's native view: projects, culls and publishes its translation. | +| `cpp/MarkerProjectionRegistry` | Translations by Fabric tag, read by the patched `HybridMarkerViewShadowNode::getTransform()` so `measure()` matches the screen. | +| `MarkerViewRenderRegistry` (Swift, Kotlin) | Takes live markers and JSX clusters out of each pipeline diff before the SDK sees it, and publishes the displayed set to JS when it changes. | +| `scripts/patch-nitrogen-generated.mjs` | Lets the generated Nitro views take Fabric children, and installs the shadow-node transform. Fails loudly if Nitrogen's output changes shape. | diff --git a/docs/roadmap.md b/docs/roadmap.md index f86527df..189ec50c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -86,14 +86,15 @@ Delivered incrementally during Phases 3–5; polished for platform consistency i | Clustering library | Custom vs platform-native | Platform-native (MKClusterAnnotation / maps-utils) | | Provider architecture | In-place SDK switching vs adapter remount | Provider adapter + React remount | | Overlay animations | Reanimated core path vs native descriptors | Native descriptor animations; Reanimated optional | -| Custom view markers | Snapshot-only vs live/hybrid native views | Hybrid: live MapKit, snapshot Google (ADR 0004) | +| Custom view markers | Snapshot-only vs live/hybrid native views | Live projected hosts on every provider (ADR 0004) | | Offline support | Tile caching strategy | Future consideration | ## Custom view markers (ADR 0004) - [x] Phase 0: apply `MarkerDescriptor` image, anchor, centerOffset, rotation, flat, and opacity on iOS Google -- [ ] Phase 1: `` children via snapshot into existing image pipeline -- [ ] Phase 2: `` HybridView — live MapKit views, snapshot on Google +- [x] `` — live JSX hosts placed by native projection on MapKit, Google iOS and Google Android +- [x] Live markers in native clustering; `renderCluster` for JSX cluster badges +- [ ] Physical-device frame traces per provider for the live marker example ## Future provider work diff --git a/example/examples/index.ts b/example/examples/index.ts index 805827a8..9cbac421 100644 --- a/example/examples/index.ts +++ b/example/examples/index.ts @@ -15,6 +15,7 @@ import { import { deliveryZoneScenario } from './deliveryZone'; import { geojsonScenario } from './geojson'; import { landmarksScenario } from './landmarks'; +import { liveMarkersScenario } from './liveMarkers'; import { mountEffectCameraScenario } from './mountEffectCamera'; import { createScenarioOverlayProps } from './overlaySource'; import { riverRouteScenario } from './riverRoute'; @@ -35,6 +36,7 @@ export const MAP_SCENARIOS: MapScenario[] = [ allOverlaysScenario, landmarksScenario, customMarkerImagesScenario, + liveMarkersScenario, riverRouteScenario, deliveryZoneScenario, geojsonScenario, diff --git a/example/examples/liveMarkers.tsx b/example/examples/liveMarkers.tsx new file mode 100644 index 00000000..a795dbba --- /dev/null +++ b/example/examples/liveMarkers.tsx @@ -0,0 +1,200 @@ +import { useEffect, type ReactNode } from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import Animated, { + Easing, + cancelAnimation, + useAnimatedStyle, + useSharedValue, + withRepeat, + withTiming, +} from 'react-native-reanimated'; +import { + MarkerView, + type MarkerViewCluster, + type RenderMarkerViewCluster, +} from 'react-native-better-maps'; +import type { LivePlace, MapScenario } from './types'; + +export const LIVE_MARKERS_SCENARIO_ID = 'live-markers'; + +const CENTER = { latitude: 52.2297, longitude: 21.0122 }; +const PLACE_COUNT = 120; + +/** Deterministic spread around the center, so every run shows the same map. */ +function makePlaces(): LivePlace[] { + const places: LivePlace[] = []; + for (let index = 0; index < PLACE_COUNT; index += 1) { + const angle = index * 2.399963; // golden angle + const radius = 0.004 * Math.sqrt(index + 1); + places.push({ + id: `place-${index}`, + coordinate: { + latitude: CENTER.latitude + radius * Math.sin(angle), + longitude: CENTER.longitude + radius * 1.6 * Math.cos(angle), + }, + price: 80 + ((index * 37) % 420), + // Every few places has a live animation, to keep animated hosts on screen. + isHot: index % 7 === 0, + }); + } + return places; +} + +export const liveMarkersScenario: MapScenario = { + id: LIVE_MARKERS_SCENARIO_ID, + name: 'Live JSX markers', + description: + 'MarkerView price bubbles with Pressable and Reanimated, clustered natively, JSX cluster badges', + region: { + ...CENTER, + latitudeDelta: 0.09, + longitudeDelta: 0.11, + }, + advanced: { + clusteringEnabled: true, + }, + livePlaces: makePlaces(), +}; + +/** Direct `MapView` children: one `MarkerView` per place. */ +export function liveMarkerChildren( + places: LivePlace[], + onPressPlace: (label: string) => void, +): ReactNode { + return places.map((place) => ( + + + + )); +} + +export const renderLiveCluster: RenderMarkerViewCluster = (cluster) => ( + + + +); + +const BUBBLE_WIDTH = 76; +const BUBBLE_HEIGHT = 40; +const BUBBLE_ANCHOR = { x: 0.5, y: 1 }; +const CLUSTER_SIZE = 52; +const CLUSTER_ANCHOR = { x: 0.5, y: 0.5 }; + +function PriceBubble({ + place, + onPress, +}: { + place: LivePlace; + onPress: (label: string) => void; +}) { + return ( + onPress(`${place.id} · ${place.price} zł`)} + onLongPress={() => onPress(`Long press · ${place.id}`)} + style={styles.bubbleHitArea} + > + {({ pressed }) => ( + + {place.isHot ? : null} + {place.price} zł + + )} + + ); +} + +/** A transform-only loop: Reanimated runs it on the UI thread, no React renders. */ +function Pulse() { + const scale = useSharedValue(1); + useEffect(() => { + scale.value = withRepeat( + withTiming(1.6, { duration: 900, easing: Easing.out(Easing.quad) }), + -1, + false, + ); + return () => cancelAnimation(scale); + }, [scale]); + const style = useAnimatedStyle(() => ({ + opacity: 2 - scale.value, + transform: [{ scale: scale.value }], + })); + return ; +} + +function ClusterBubble({ cluster }: { cluster: MarkerViewCluster }) { + return ( + { + cluster.onPress().catch(() => {}); + }} + style={({ pressed }) => [styles.cluster, pressed && styles.bubblePressed]} + > + {cluster.count} + places + + ); +} + +const styles = StyleSheet.create({ + bubbleHitArea: { + flex: 1, + alignItems: 'center', + justifyContent: 'flex-end', + }, + bubble: { + minWidth: 60, + height: 30, + paddingHorizontal: 10, + borderRadius: 15, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#FFFFFF', + borderWidth: 1, + borderColor: 'rgba(0, 0, 0, 0.12)', + }, + bubblePressed: { + backgroundColor: '#1F6FEB', + }, + bubbleText: { + fontSize: 13, + fontWeight: '700', + color: '#111827', + }, + pulse: { + position: 'absolute', + width: 30, + height: 30, + borderRadius: 15, + backgroundColor: 'rgba(255, 99, 71, 0.45)', + }, + cluster: { + width: CLUSTER_SIZE, + height: CLUSTER_SIZE, + borderRadius: CLUSTER_SIZE / 2, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#111827', + borderWidth: 3, + borderColor: '#FFFFFF', + }, + clusterCount: { + fontSize: 15, + fontWeight: '800', + color: '#FFFFFF', + }, + clusterLabel: { + fontSize: 9, + color: '#D1D5DB', + }, +}); diff --git a/example/examples/overlaySource.tsx b/example/examples/overlaySource.tsx index 7c0fd43e..496e94e3 100644 --- a/example/examples/overlaySource.tsx +++ b/example/examples/overlaySource.tsx @@ -3,6 +3,7 @@ import { type Coordinate, type MapViewProps, } from 'react-native-better-maps'; +import { liveMarkerChildren, renderLiveCluster } from './liveMarkers'; import type { MapScenario } from './types'; type ScenarioOverlayProps = Pick< @@ -12,6 +13,7 @@ type ScenarioOverlayProps = Pick< | 'polygons' | 'circles' | 'children' + | 'renderCluster' | 'onMarkerPress' | 'onMarkerDragEnd' | 'onPolylinePress' @@ -32,9 +34,10 @@ export function createScenarioOverlayProps( polylines: scenario.polylines, polygons: scenario.polygons, circles: scenario.circles, - children: - geojson == null ? undefined : ( + children: [ + geojson == null ? null : ( ), + scenario.livePlaces == null + ? null + : liveMarkerChildren(scenario.livePlaces, onOverlayPress), + ], + renderCluster: scenario.livePlaces == null ? undefined : renderLiveCluster, onMarkerPress: scenario.markers != null ? onMarkerPress : undefined, onMarkerDragEnd: scenario.markers != null ? onMarkerDragEnd : undefined, onPolylinePress: scenario.polylines != null ? onOverlayPress : undefined, diff --git a/example/examples/types.ts b/example/examples/types.ts index b4ea0df2..dda3c309 100644 --- a/example/examples/types.ts +++ b/example/examples/types.ts @@ -1,5 +1,6 @@ import type { ApplePoiDetailPresentation, + Coordinate, EdgePadding, GeojsonInput, GeojsonProps, @@ -20,6 +21,14 @@ export interface MapScenarioAdvancedOptions { fitToCoordinatesOnMount?: boolean; } +/** A place drawn as a live `MarkerView`. */ +export interface LivePlace { + id: string; + coordinate: Coordinate; + price: number; + isHot: boolean; +} + export interface MapScenario { id: string; name: string; @@ -30,6 +39,8 @@ export interface MapScenario { polygons?: NonNullable; circles?: NonNullable; geojson?: GeojsonInput; + /** Drawn as `MarkerView` children, with JSX cluster badges. */ + livePlaces?: LivePlace[]; geojsonStyle?: Pick< GeojsonProps, 'strokeColor' | 'fillColor' | 'strokeWidth' diff --git a/package/android/CMakeLists.txt b/package/android/CMakeLists.txt index d060b9db..3cc37d1a 100644 --- a/package/android/CMakeLists.txt +++ b/package/android/CMakeLists.txt @@ -6,7 +6,10 @@ set(CMAKE_VERBOSE_MAKEFILE ON) set(CMAKE_CXX_STANDARD 20) # Define the C++ library and add all sources -add_library(${PACKAGE_NAME} SHARED src/main/cpp/cpp-adapter.cpp) +add_library(${PACKAGE_NAME} SHARED + src/main/cpp/cpp-adapter.cpp + ../cpp/MarkerProjectionRegistry.cpp +) # Add the Nitrogen-generated specs include(${CMAKE_SOURCE_DIR}/../nitrogen/generated/android/NitroMaps+autolinking.cmake) diff --git a/package/android/build.gradle b/package/android/build.gradle index f67f3075..a3548153 100644 --- a/package/android/build.gradle +++ b/package/android/build.gradle @@ -74,6 +74,11 @@ android { prefab true } + testOptions { + // Robolectric needs the merged resources for the touch-routing tests. + unitTests.includeAndroidResources = true + } + compileOptions { sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 @@ -97,4 +102,5 @@ dependencies { implementation 'com.google.android.gms:play-services-location:21.3.0' implementation 'com.google.maps.android:android-maps-utils:3.8.2' testImplementation 'junit:junit:4.13.2' + testImplementation 'org.robolectric:robolectric:4.16.1' } diff --git a/package/android/src/main/cpp/cpp-adapter.cpp b/package/android/src/main/cpp/cpp-adapter.cpp index 83e24921..2ae3f62a 100644 --- a/package/android/src/main/cpp/cpp-adapter.cpp +++ b/package/android/src/main/cpp/cpp-adapter.cpp @@ -1,7 +1,17 @@ +#include "MarkerProjectionRegistry.hpp" #include "NitroMapsOnLoad.hpp" #include #include +extern "C" JNIEXPORT void JNICALL Java_com_margelo_nitro_nitromaps_MarkerProjectionRegistry_publish(JNIEnv*, jclass, jint tag, + jfloat x, jfloat y) { + margelo::nitro::nitromaps::publishMarkerProjection(tag, x, y); +} + +extern "C" JNIEXPORT void JNICALL Java_com_margelo_nitro_nitromaps_MarkerProjectionRegistry_clear(JNIEnv*, jclass, jint tag) { + margelo::nitro::nitromaps::clearMarkerProjection(tag); +} + JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void*) { return facebook::jni::initialize(vm, []() { margelo::nitro::nitromaps::registerAllNatives(); }); } diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/GoogleMapProviderAdapter.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/GoogleMapProviderAdapter.kt index f106b08c..1216c7f5 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/GoogleMapProviderAdapter.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/GoogleMapProviderAdapter.kt @@ -107,6 +107,7 @@ class GoogleMapProviderAdapter( view.getMapAsync { map -> googleMap = map configureMap(map) + onCameraMoved?.invoke() } installViewportSizeListener(view) @@ -216,6 +217,26 @@ class GoogleMapProviderAdapter( } } + override var onCameraMoved: (() -> Unit)? = null + + /** One `Projection` per camera frame, shared by every live marker. */ + override fun makeMarkerProjection(): MarkerProjection? { + val projection = googleMap?.projection ?: return null + return { coordinate -> projection.toScreenLocation(LatLng(coordinate.latitude, coordinate.longitude)) } + } + + override var customClusterViews: Boolean? = null + set(value) { + field = value + overlayController.setCustomClusterViews(value == true) + } + + override var onMarkerViewRenderState: ((NativeMarkerViewRenderState) -> Unit)? = null + set(value) { + field = value + overlayController.onMarkerViewRenderState = value + } + private var _clusteringEnabled: Boolean? = null override var clusteringEnabled: Boolean? get() = _clusteringEnabled @@ -498,10 +519,12 @@ class GoogleMapProviderAdapter( ) } map.setOnCameraMoveListener { + onCameraMoved?.invoke() overlayController.onCameraMove() regionChanges.cameraMoved() } map.setOnCameraIdleListener { + onCameraMoved?.invoke() overlayController.onCameraIdle() regionChanges.cameraStopped() } @@ -851,6 +874,8 @@ class GoogleMapProviderAdapter( override fun release() { // Drop the JS callbacks first: a map event still in flight must not reach a // view that is already gone. + onCameraMoved = null + onMarkerViewRenderState = null onRegionChange = null onRegionChangeComplete = null onMapReady = null diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/HybridMapView.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/HybridMapView.kt index 97a3eaaa..151ffeac 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/HybridMapView.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/HybridMapView.kt @@ -1,7 +1,7 @@ package com.margelo.nitro.nitromaps import android.view.View -import android.widget.FrameLayout +import android.view.ViewGroup import androidx.annotation.Keep import com.facebook.proguard.annotations.DoNotStrip import com.facebook.react.uimanager.ThemedReactContext @@ -51,7 +51,7 @@ class HybridMapView( private var _markerEnteringAnimation: OverlayEnteringAnimationDescriptor? = null private var _clusterEnteringAnimation: OverlayEnteringAnimationDescriptor? = null - override val view: FrameLayout = FrameLayout(context) + override val view = NitroMapContainerView(context) override var provider: MapProvider? get() = _provider @@ -89,6 +89,7 @@ class HybridMapView( get() = _scrollEnabled set(value) { _scrollEnabled = value + view.scrollGesturesEnabled = value != false adapter?.scrollEnabled = value } @@ -96,6 +97,7 @@ class HybridMapView( get() = _zoomEnabled set(value) { _zoomEnabled = value + updateMultiTouchGestures() adapter?.zoomEnabled = value } @@ -103,6 +105,7 @@ class HybridMapView( get() = _rotateEnabled set(value) { _rotateEnabled = value + updateMultiTouchGestures() adapter?.rotateEnabled = value } @@ -110,9 +113,15 @@ class HybridMapView( get() = _pitchEnabled set(value) { _pitchEnabled = value + updateMultiTouchGestures() adapter?.pitchEnabled = value } + /** Whether a pinch that starts on a live marker is the map's to handle. */ + private fun updateMultiTouchGestures() { + view.multiTouchGesturesEnabled = _zoomEnabled != false || _rotateEnabled != false || _pitchEnabled != false + } + override var showsUserLocation: Boolean? get() = _showsUserLocation set(value) { @@ -168,6 +177,18 @@ class HybridMapView( adapter?.clusteringEnabled = value } + override var customClusterViews: Boolean? = null + set(value) { + field = value + adapter?.customClusterViews = value + } + + override var onMarkerViewRenderState: ((state: NativeMarkerViewRenderState) -> Unit)? = null + set(value) { + field = value + adapter?.onMarkerViewRenderState = value + } + override var mapPadding: EdgePadding? get() = _mapPadding set(value) { @@ -319,6 +340,7 @@ class HybridMapView( override fun afterUpdate() { val nextAdapter = adapterSlot.commit(AdapterConfiguration(_provider, _googleMapId)) ?: return attach(nextAdapter.view) + attachMarkers(nextAdapter) syncState(nextAdapter) } @@ -336,6 +358,8 @@ class HybridMapView( _zoomEnabled = true _rotateEnabled = true _pitchEnabled = true + view.scrollGesturesEnabled = true + view.multiTouchGesturesEnabled = true _showsUserLocation = null _followsUserLocation = null _showsCompass = null @@ -344,6 +368,8 @@ class HybridMapView( _customMapStyle = null _googleMapId = null _clusteringEnabled = null + customClusterViews = null + onMarkerViewRenderState = null _mapPadding = null _markerEnteringAnimation = null _clusterEnteringAnimation = null @@ -400,20 +426,34 @@ class HybridMapView( * Native has view recycling enabled. */ private fun destroyAdapter(outgoing: MapProviderAdapter) { + outgoing.onCameraMoved = null + if (view.mapSurface === outgoing.view) { + view.mapSurface = null + view.makeProjection = null + } outgoing.release() view.removeView(outgoing.view) } + /** Live markers follow the adapter's camera. */ + private fun attachMarkers(adapter: MapProviderAdapter) { + view.mapSurface = adapter.view + view.makeProjection = adapter::makeMarkerProjection + adapter.onCameraMoved = view::updateMarkerPositions + } + /** Only ever sees [MapProvider.GOOGLE]: the [provider] setter rejects every other one. */ private fun makeAdapter(configuration: AdapterConfiguration): MapProviderAdapter = GoogleMapProviderAdapter(context, configuration.googleMapId) + /** Below the live markers, which are drawn over the map. */ private fun attach(contentView: View) { view.addView( contentView, - FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, - FrameLayout.LayoutParams.MATCH_PARENT, + 0, + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, ), ) } @@ -433,6 +473,9 @@ class HybridMapView( adapter.showsScale = _showsScale adapter.customMapStyle = _customMapStyle adapter.googleMapId = _googleMapId + // Before the markers: the first diff must already know what is live. + adapter.customClusterViews = customClusterViews + adapter.onMarkerViewRenderState = onMarkerViewRenderState adapter.clusteringEnabled = _clusteringEnabled adapter.markerEnteringAnimation = _markerEnteringAnimation adapter.clusterEnteringAnimation = _clusterEnteringAnimation diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/HybridMarkerView.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/HybridMarkerView.kt new file mode 100644 index 00000000..795b7be7 --- /dev/null +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/HybridMarkerView.kt @@ -0,0 +1,30 @@ +package com.margelo.nitro.nitromaps + +import androidx.annotation.Keep +import com.facebook.proguard.annotations.DoNotStrip +import com.facebook.react.uimanager.ThemedReactContext + +/** Native host of a live `MarkerView`. The map it is mounted in positions it. */ +@Keep +@DoNotStrip +class HybridMarkerView( + context: ThemedReactContext, +) : HybridMarkerViewSpec() { + override val view = NitroMarkerContentView(context) + + override var coordinate = Coordinate(0.0, 0.0) + set(value) { + field = value + view.coordinate = value + } + + override var anchor: MarkerAnchor? = null + set(value) { + field = value + view.anchor = value + } + + override fun afterUpdate() { + view.updatePosition((view.parent as? NitroMapContainerView)?.makeProjection?.invoke()) + } +} diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/MapOverlayController.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/MapOverlayController.kt index 5faeeecf..82e1ecfd 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/MapOverlayController.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/MapOverlayController.kt @@ -29,6 +29,7 @@ internal class MapOverlayController( private val density: Float = context.resources.displayMetrics.density private val markerIconFactory = MarkerIconFactory(context, density) { markers } private val markerVersions = HashMap() + private val markerViews = MarkerViewRenderRegistry() private val clusterByKey = HashMap() private val polylines = LinkedHashMap() private val polygons = LinkedHashMap() @@ -102,6 +103,37 @@ internal class MapOverlayController( } } + var onMarkerViewRenderState: ((NativeMarkerViewRenderState) -> Unit)? + get() = markerViews.onChange + set(value) { + markerViews.onChange = value + } + + /** + * Switching who draws clusters redraws every marker: a cluster on screen belongs to the SDK or to + * JS, and the diff cannot move it between them. + */ + fun setCustomClusterViews(enabled: Boolean) { + if (!markerViews.setCustomClusters(enabled)) { + return + } + removeAllMarkers() + reapplyMarkers() + } + + private fun removeAllMarkers() { + markerEnterAnimators.values.toSet().forEach { it.cancel() } + markerEnterAnimators.clear() + markers.values.forEach { it.remove() } + markers.clear() + markerVersions.clear() + clusterByKey.clear() + markerViews.reset() + } + + /** What the pipeline diffs against: the SDK markers and the live hosts. */ + private fun displayedVersions(): HashMap = HashMap(markerViews.displayedVersions(markerVersions)) + fun setMarkerPressHandlers( onMarkerPress: ((String) -> Unit)?, onClusterPress: ((List, Coordinate) -> Unit)?, @@ -111,6 +143,7 @@ internal class MapOverlayController( } fun clear() { + markerViews.reset() markerEnterAnimators.values.toSet().forEach { it.cancel() } cancelIdleRefresh() cancelLiveRefresh() @@ -181,7 +214,7 @@ internal class MapOverlayController( clustering = renderState.clusteringEnabled, widthPx = viewWidthPx, heightPx = viewHeightPx, - displayedVersions = HashMap(markerVersions), + displayedVersions = displayedVersions(), animateEntering = animateEntering, maxAnimatedMarkers = maxAnimatedMarkers, ) @@ -264,11 +297,12 @@ internal class MapOverlayController( } private fun applyDiff( - diff: MarkerRenderDiff, + pipelineDiff: MarkerRenderDiff, animateEntering: Boolean = true, maxAnimatedMarkers: Int = MAX_ANIMATED_MARKERS_PER_DIFF, ) { val map = googleMap ?: return + val diff = markerViews.consume(pipelineDiff) for (key in diff.removedKeys) { cancelEnteringAnimation(key) @@ -466,6 +500,11 @@ internal class MapOverlayController( refreshGeneration += 1 cancelIdleRefresh() cancelLiveRefresh() + if (markerViews.hasEntries || descriptors.any { it.liveView == true }) { + // Live markers go through the diff, which takes them out before the SDK sees it. + applyDiff(computeMarkerRenderDiff(descriptors.map { ClusterElement.Single(it) }, displayedVersions())) + return + } markerVersions.clear() clusterByKey.clear() reconcile( diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/MapProviderAdapter.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/MapProviderAdapter.kt index fe1cd741..81b80e55 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/MapProviderAdapter.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/MapProviderAdapter.kt @@ -6,6 +6,12 @@ import com.margelo.nitro.core.Promise interface MapProviderAdapter { val view: View + /** Called on every camera frame, gestures and animations alike, on the main thread. */ + var onCameraMoved: (() -> Unit)? + + /** A projection for the current camera, or null while there is no map to project with. */ + fun makeMarkerProjection(): MarkerProjection? + var mapType: MapType var region: Region? var camera: Camera? @@ -20,6 +26,8 @@ interface MapProviderAdapter { var customMapStyle: String? var googleMapId: String? var clusteringEnabled: Boolean? + var customClusterViews: Boolean? + var onMarkerViewRenderState: ((NativeMarkerViewRenderState) -> Unit)? var mapPadding: EdgePadding? var markerEnteringAnimation: OverlayEnteringAnimationDescriptor? var clusterEnteringAnimation: OverlayEnteringAnimationDescriptor? diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerDescriptor+DisplayedIdentity.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerDescriptor+DisplayedIdentity.kt index 84ca771c..1347ca6b 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerDescriptor+DisplayedIdentity.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerDescriptor+DisplayedIdentity.kt @@ -10,6 +10,7 @@ internal fun MarkerDescriptor.displayedIdentityVersion(): Long = subtitle, draggable, clusterable, + liveView, image?.uri, image?.width, image?.height, diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerProjectionRegistry.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerProjectionRegistry.kt new file mode 100644 index 00000000..c62b53f1 --- /dev/null +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerProjectionRegistry.kt @@ -0,0 +1,20 @@ +package com.margelo.nitro.nitromaps + +import androidx.annotation.Keep +import com.facebook.proguard.annotations.DoNotStrip + +/** + * Shares where each live marker host is translated to with Fabric's shadow nodes, without a JS + * update. Backed by `cpp/MarkerProjectionRegistry`; values are in dp, keyed by Fabric tag. + */ +@Keep +@DoNotStrip +internal object MarkerProjectionRegistry { + @JvmStatic external fun publish( + tag: Int, + x: Float, + y: Float, + ) + + @JvmStatic external fun clear(tag: Int) +} diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerViewRenderRegistry.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerViewRenderRegistry.kt new file mode 100644 index 00000000..7185a9d5 --- /dev/null +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerViewRenderRegistry.kt @@ -0,0 +1,140 @@ +package com.margelo.nitro.nitromaps + +/** + * Splits the marker pipeline's output between the SDK and live `MarkerView` hosts. Live elements + * are taken out of every diff before it reaches the SDK, and JS hears which live hosts to mount + * whenever that set changes. + * + * `MarkerViewRenderRegistry.swift` is the iOS counterpart; change the two together. + */ +internal class MarkerViewRenderRegistry { + /** Whether clusters are live hosts (`renderCluster`) rather than SDK badges. */ + var customClusters = false + private set + + var onChange: ((NativeMarkerViewRenderState) -> Unit)? = null + set(value) { + field = value + publish() + } + + private val entries = HashMap() + + /** Whether any live element is displayed, so the synchronous SDK path cannot be used as is. */ + val hasEntries: Boolean + get() = entries.isNotEmpty() + + /** [sdkVersions] plus the live elements, which count as displayed too: the pipeline diffs against both. */ + fun displayedVersions(sdkVersions: Map): Map { + if (entries.isEmpty()) { + return sdkVersions + } + val versions = HashMap(sdkVersions) + entries.forEach { (key, element) -> versions[key] = element.renderVersion } + return versions + } + + /** Returns whether the setting changed; the caller then redraws every marker. */ + fun setCustomClusters(enabled: Boolean): Boolean { + if (customClusters == enabled) { + return false + } + customClusters = enabled + return true + } + + fun reset() { + if (entries.isEmpty()) { + return + } + entries.clear() + publish() + } + + /** Takes the live elements out of [diff] and returns what the SDK still has to do. */ + fun consume(diff: MarkerRenderDiff): MarkerRenderDiff { + val removed = HashSet(diff.removedKeys) + val added = ArrayList() + val retained = ArrayList() + var changed = false + + for (key in diff.removedKeys) { + if (entries.remove(key) != null) changed = true + } + + fun route( + element: ClusterElement, + isNew: Boolean, + ) { + val key = element.diffKey + when { + isLive(element) -> { + val previous = entries.put(key, element) + if (previous == null) { + // Retained but not live until now: the SDK drew it, so it goes. + if (!isNew) removed.add(key) + changed = true + } else if (element is ClusterElement.Cluster) { + // A cluster's position, count and members come from here; a live marker's own props + // come from JS, which need not hear about them. + changed = true + } + } + + entries.remove(key) != null -> { + // Live until now, so the SDK has nothing to update: it adds it. + added.add(element) + changed = true + } + + isNew -> { + added.add(element) + } + + else -> { + retained.add(element) + } + } + } + + diff.added.forEach { route(it, isNew = true) } + diff.retained.forEach { route(it, isNew = false) } + + if (changed) publish() + return MarkerRenderDiff(removed, added, retained) + } + + private fun isLive(element: ClusterElement): Boolean = + when (element) { + is ClusterElement.Single -> element.descriptor.liveView == true + is ClusterElement.Cluster -> customClusters + } + + private fun publish() { + val callback = onChange ?: return + val markerIds = ArrayList() + val clusters = ArrayList() + for (key in entries.keys.sorted()) { + when (val element = entries[key]) { + is ClusterElement.Single -> { + markerIds.add(element.descriptor.id) + } + + is ClusterElement.Cluster -> { + clusters.add( + NativeMarkerViewCluster( + key, + Coordinate(element.position.latitude, element.position.longitude), + element.memberIds.sorted().toTypedArray(), + ), + ) + } + + null -> { + Unit + } + } + } + callback(NativeMarkerViewRenderState(markerIds.toTypedArray(), clusters.toTypedArray())) + } +} diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/NitroMapContainerView.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/NitroMapContainerView.kt new file mode 100644 index 00000000..2b3b104c --- /dev/null +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/NitroMapContainerView.kt @@ -0,0 +1,228 @@ +package com.margelo.nitro.nitromaps + +import android.annotation.SuppressLint +import android.content.Context +import android.view.MotionEvent +import android.view.View +import android.view.ViewConfiguration +import android.view.ViewGroup +import com.facebook.react.uimanager.events.NativeGestureUtil + +/** + * Projects coordinates to pixels in the map view for one pass over the live markers. Built once per + * camera frame, so every host reuses one projection snapshot. + */ +typealias MarkerProjection = (Coordinate) -> android.graphics.Point? + +/** + * The view `HybridMapView` hands to Fabric: the provider's map view with the live `MarkerView` hosts + * drawn over it. + * + * Fabric only sees the hosts - the patched `HybridMapViewManager` routes its child calls to + * [addMarkerView] and friends - so the SDK view never shifts Fabric's child indices. + * + * A host consumes the touches that start on it, as every React Native view does. A drag or a pinch + * that starts on one is handed to the map once it moves past the touch slop, unless a view inside the + * marker - a `ScrollView`, say - claimed it first. + */ +class NitroMapContainerView( + context: Context, +) : ViewGroup(context) { + /** Returns the projection for the current camera, or null while there is no map to project with. */ + internal var makeProjection: (() -> MarkerProjection?)? = null + set(value) { + field = value + updateMarkerPositions() + } + + /** The provider's map view, laid out under the markers. */ + var mapSurface: View? = null + + var scrollGesturesEnabled = true + var multiTouchGesturesEnabled = true + + private val markerViews = mutableListOf() + private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop + private var markerDown: MotionEvent? = null + private var forwardingGesture = false + private var descendantOwnsGesture = false + + init { + clipChildren = true + } + + val markerViewCount: Int + get() = markerViews.size + + fun markerViewAt(index: Int): View = markerViews[index] + + fun addMarkerView( + child: View, + index: Int, + ) { + // Only MarkerView hosts are rendered into a MapView. + val marker = child as? NitroMarkerContentView ?: return + val position = index.coerceIn(0, markerViews.size) + val sdkViewCount = childCount - markerViews.size + markerViews.add(position, marker) + addView(marker, sdkViewCount + position) + marker.updatePosition(makeProjection?.invoke()) + } + + fun removeMarkerViewAt(index: Int) { + removeView(markerViews.removeAt(index)) + } + + fun removeAllMarkerViews() { + markerViews.forEach(::removeView) + markerViews.clear() + } + + /** Repositions every host for the current camera: one projection, a translation per host that moved. */ + fun updateMarkerPositions() { + if (markerViews.isEmpty()) { + return + } + val projection = makeProjection?.invoke() + markerViews.forEach { it.updatePosition(projection) } + } + + @SuppressLint("ClickableViewAccessibility") + override fun dispatchTouchEvent(event: MotionEvent): Boolean { + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + clearMarkerGesture() + if (isOnMarker(event.x, event.y)) { + markerDown = MotionEvent.obtain(event) + } + } + + // A ScrollView in a marker claims its gesture on the first MOVE past its slop, not on DOWN, so + // the children see each event before the map may take it over. + val handled = if (forwardingGesture) false else super.dispatchTouchEvent(event) + + val down = markerDown + val surface = mapSurface + if (down != null && surface != null) { + if (!forwardingGesture && !descendantOwnsGesture && startsMapGesture(event, down)) { + startForwarding(event, down, surface) + } + if (forwardingGesture) { + surface.dispatchTouchEvent(event) + if (event.isEndOfGesture()) { + NativeGestureUtil.notifyNativeGestureEnded(this, event) + clearMarkerGesture() + } + return true + } + } + + if (event.isEndOfGesture()) { + clearMarkerGesture() + } + return handled + } + + override fun requestDisallowInterceptTouchEvent(disallowIntercept: Boolean) { + descendantOwnsGesture = disallowIntercept + super.requestDisallowInterceptTouchEvent(disallowIntercept) + } + + override fun onDetachedFromWindow() { + val down = markerDown + if (forwardingGesture && down != null) { + val cancel = MotionEvent.obtain(down) + cancel.action = MotionEvent.ACTION_CANCEL + mapSurface?.dispatchTouchEvent(cancel) + NativeGestureUtil.notifyNativeGestureEnded(this, cancel) + cancel.recycle() + } + clearMarkerGesture() + super.onDetachedFromWindow() + } + + override fun onMeasure( + widthMeasureSpec: Int, + heightMeasureSpec: Int, + ) { + setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), MeasureSpec.getSize(heightMeasureSpec)) + // Fabric measures and lays out the hosts itself. + for (index in 0 until childCount) { + val child = getChildAt(index) + if (child !is NitroMarkerContentView) { + child.measure(widthMeasureSpec, heightMeasureSpec) + } + } + } + + override fun onLayout( + changed: Boolean, + left: Int, + top: Int, + right: Int, + bottom: Int, + ) { + for (index in 0 until childCount) { + val child = getChildAt(index) + if (child !is NitroMarkerContentView) { + child.layout(0, 0, right - left, bottom - top) + } + } + updateMarkerPositions() + } + + private fun isOnMarker( + x: Float, + y: Float, + ): Boolean = + markerViews.any { marker -> + marker.visibility == VISIBLE && + x >= marker.x && + x < marker.x + marker.width && + y >= marker.y && + y < marker.y + marker.height + } + + private fun startsMapGesture( + event: MotionEvent, + down: MotionEvent, + ): Boolean = + when (event.actionMasked) { + MotionEvent.ACTION_POINTER_DOWN -> { + multiTouchGesturesEnabled + } + + MotionEvent.ACTION_MOVE -> { + val dx = event.x - down.x + val dy = event.y - down.y + scrollGesturesEnabled && dx * dx + dy * dy > touchSlop * touchSlop + } + + else -> { + false + } + } + + /** Cancels the marker's touches - JS sees its responder terminate - and replays the DOWN to the map. */ + private fun startForwarding( + event: MotionEvent, + down: MotionEvent, + surface: View, + ) { + NativeGestureUtil.notifyNativeGestureStarted(this, event) + val cancel = MotionEvent.obtain(event) + cancel.action = MotionEvent.ACTION_CANCEL + super.dispatchTouchEvent(cancel) + cancel.recycle() + surface.dispatchTouchEvent(down) + forwardingGesture = true + } + + private fun clearMarkerGesture() { + markerDown?.recycle() + markerDown = null + forwardingGesture = false + descendantOwnsGesture = false + } + + private fun MotionEvent.isEndOfGesture(): Boolean = actionMasked == MotionEvent.ACTION_UP || actionMasked == MotionEvent.ACTION_CANCEL +} diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/NitroMapsPackage.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/NitroMapsPackage.kt index 6b0ebefb..7292602e 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/NitroMapsPackage.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/NitroMapsPackage.kt @@ -6,6 +6,7 @@ import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.module.model.ReactModuleInfoProvider import com.facebook.react.uimanager.ViewManager import com.margelo.nitro.nitromaps.views.HybridMapViewManager +import com.margelo.nitro.nitromaps.views.HybridMarkerViewManager /** * React Native package that registers the `MapView` Nitro HybridView's view @@ -24,7 +25,7 @@ class NitroMapsPackage : BaseReactPackage() { } override fun createViewManagers(reactContext: ReactApplicationContext): List> { - return listOf(HybridMapViewManager()) + return listOf(HybridMapViewManager(), HybridMarkerViewManager()) } companion object { diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/NitroMarkerContentView.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/NitroMarkerContentView.kt new file mode 100644 index 00000000..4a5afeb3 --- /dev/null +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/NitroMarkerContentView.kt @@ -0,0 +1,111 @@ +package com.margelo.nitro.nitromaps + +import android.content.Context +import android.view.ViewGroup +import kotlin.math.roundToInt + +/** + * The host of a live `MarkerView`. Fabric mounts the marker's JSX in it and lays it out; the map + * only moves it, by translation, to where the camera projects its coordinate. + */ +class NitroMarkerContentView( + context: Context, +) : ViewGroup(context) { + var coordinate: Coordinate? = null + var anchor: MarkerAnchor? = null + + private var publishedTag = NO_ID + private var publishedX = Float.NaN + private var publishedY = Float.NaN + + init { + clipChildren = true + } + + override fun onLayout( + changed: Boolean, + left: Int, + top: Int, + right: Int, + bottom: Int, + ) { + // Fabric resized the host; its anchor point moved with it. + updatePosition((parent as? NitroMapContainerView)?.makeProjection?.invoke()) + } + + override fun onDetachedFromWindow() { + clearPublishedProjection() + super.onDetachedFromWindow() + } + + /** + * Places the host for [projection], the camera's current one. A marker off screen is made + * invisible, which also takes it out of drawing and of touch handling. + */ + internal fun updatePosition(projection: MarkerProjection?) { + val map = parent as? NitroMapContainerView ?: return + val coordinate = coordinate + val point = if (coordinate == null) null else projection?.invoke(coordinate) + if (point == null || width <= 0 || height <= 0) { + setOnScreen(false) + return + } + + // Whole pixels: a fractional offset resamples the marker and blurs its text. + val x = (point.x - width * (anchor?.x ?: 0.5)).roundToInt() + val y = (point.y - height * (anchor?.y ?: 1.0)).roundToInt() + val isOnScreen = x < map.width && y < map.height && x + width > 0 && y + height > 0 + setOnScreen(isOnScreen) + if (!isOnScreen) { + return + } + + val translateX = (x - left).toFloat() + val translateY = (y - top).toFloat() + if (translationX != translateX) translationX = translateX + if (translationY != translateY) translationY = translateY + publish(translateX, translateY) + } + + private fun setOnScreen(isOnScreen: Boolean) { + val nextVisibility = if (isOnScreen) VISIBLE else INVISIBLE + if (visibility != nextVisibility) { + visibility = nextVisibility + } + if (!isOnScreen) { + clearPublishedProjection() + } + } + + /** + * Hands the translation, in dp, to the shadow node, so `measure()` reports the marker where it is + * drawn. Written only when it changed. + */ + private fun publish( + translateX: Float, + translateY: Float, + ) { + val tag = id + if (tag <= 0 || (tag == publishedTag && translateX == publishedX && translateY == publishedY)) { + return + } + if (publishedTag > 0 && publishedTag != tag) { + MarkerProjectionRegistry.clear(publishedTag) + } + val density = resources.displayMetrics.density + MarkerProjectionRegistry.publish(tag, translateX / density, translateY / density) + publishedTag = tag + publishedX = translateX + publishedY = translateY + } + + private fun clearPublishedProjection() { + if (publishedTag <= 0) { + return + } + MarkerProjectionRegistry.clear(publishedTag) + publishedTag = NO_ID + publishedX = Float.NaN + publishedY = Float.NaN + } +} diff --git a/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerDescriptorFixture.kt b/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerDescriptorFixture.kt index f1545aa1..8f9eed6c 100644 --- a/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerDescriptorFixture.kt +++ b/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerDescriptorFixture.kt @@ -12,6 +12,7 @@ internal fun marker( opacity: Double? = null, zIndex: Double? = null, enteringAnimation: OverlayEnteringAnimationDescriptor? = null, + liveView: Boolean? = null, ): MarkerDescriptor { // Named arguments on purpose: MarkerDescriptor is generated from // package/src/native/specs/overlays.ts, so reordering a field there silently @@ -23,6 +24,7 @@ internal fun marker( subtitle = "Subtitle", draggable = false, clusterable = true, + liveView = liveView, image = image, markerColor = markerColor, zIndex = zIndex, diff --git a/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerViewGestureTest.kt b/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerViewGestureTest.kt new file mode 100644 index 00000000..9d123dee --- /dev/null +++ b/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerViewGestureTest.kt @@ -0,0 +1,175 @@ +package com.margelo.nitro.nitromaps + +import android.app.Activity +import android.content.Context +import android.graphics.Point +import android.view.MotionEvent +import android.view.View +import android.widget.FrameLayout +import android.widget.ScrollView +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class MarkerViewGestureTest { + private val context: Context = RuntimeEnvironment.getApplication() + + /** Stands in for the Google map view and records what reaches it. */ + private class Surface( + context: Context, + ) : View(context) { + val actions = mutableListOf() + + override fun onTouchEvent(event: MotionEvent): Boolean { + actions.add(event.actionMasked) + return true + } + } + + private class RecordingView( + context: Context, + ) : View(context) { + val actions = mutableListOf() + + override fun onTouchEvent(event: MotionEvent): Boolean { + actions.add(event.actionMasked) + return true + } + } + + /** A 100 px marker at (150, 150) in a 400 px map, holding [child]. */ + private fun fixture(child: View): Pair { + val map = NitroMapContainerView(context) + val surface = Surface(context) + map.addView(surface) + map.mapSurface = surface + map.makeProjection = { { Point(150, 150) } } + map.layout(0, 0, 400, 400) + val marker = NitroMarkerContentView(context) + marker.coordinate = Coordinate(0.0, 0.0) + marker.anchor = MarkerAnchor(0.0, 0.0) + marker.addView(child) + val exactly100 = View.MeasureSpec.makeMeasureSpec(100, View.MeasureSpec.EXACTLY) + child.measure(exactly100, exactly100) + child.layout(0, 0, 100, 100) + marker.layout(0, 0, 100, 100) + map.addMarkerView(marker, 0) + return map to surface + } + + private fun send( + map: NitroMapContainerView, + action: Int, + y: Float, + time: Long, + ) { + val event = MotionEvent.obtain(0, time, action, 175f, y, 0) + map.dispatchTouchEvent(event) + event.recycle() + } + + @Test + fun `a ScrollView in the marker keeps the drag it claimed`() { + val scroll = ScrollView(context) + // A ScrollView measures its content unconstrained, where a plain View is only as tall as its minimum. + scroll.addView(View(context).apply { minimumHeight = 1000 }, FrameLayout.LayoutParams(100, 1000)) + val (map, surface) = fixture(scroll) + + send(map, MotionEvent.ACTION_DOWN, 235f, 0) + send(map, MotionEvent.ACTION_MOVE, 185f, 20) + send(map, MotionEvent.ACTION_MOVE, 165f, 40) + send(map, MotionEvent.ACTION_UP, 165f, 60) + + assertTrue("the map must not see a drag the marker owns", surface.actions.isEmpty()) + assertTrue("the ScrollView scrolls instead of being cancelled", scroll.scrollY > 0) + } + + @Test + fun `a drag that starts on a marker pans the map and cancels the marker`() { + val child = RecordingView(context) + val (map, surface) = fixture(child) + + send(map, MotionEvent.ACTION_DOWN, 235f, 0) + send(map, MotionEvent.ACTION_MOVE, 185f, 20) + send(map, MotionEvent.ACTION_UP, 185f, 40) + + assertEquals(listOf(MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE, MotionEvent.ACTION_CANCEL), child.actions) + assertEquals(listOf(MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE, MotionEvent.ACTION_UP), surface.actions) + } + + @Test + fun `a tap stays with the marker`() { + val child = RecordingView(context) + val (map, surface) = fixture(child) + + send(map, MotionEvent.ACTION_DOWN, 235f, 0) + send(map, MotionEvent.ACTION_MOVE, 236f, 10) + send(map, MotionEvent.ACTION_UP, 236f, 20) + + assertEquals(listOf(MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE, MotionEvent.ACTION_UP), child.actions) + assertTrue(surface.actions.isEmpty()) + } + + @Test + fun `with map scrolling disabled a drag stays with the marker`() { + val child = RecordingView(context) + val (map, surface) = fixture(child) + map.scrollGesturesEnabled = false + + send(map, MotionEvent.ACTION_DOWN, 235f, 0) + send(map, MotionEvent.ACTION_MOVE, 185f, 20) + send(map, MotionEvent.ACTION_UP, 185f, 40) + + assertEquals(listOf(MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE, MotionEvent.ACTION_UP), child.actions) + assertTrue(surface.actions.isEmpty()) + } + + @Test + fun `a marker off screen is invisible and takes no touches`() { + val child = RecordingView(context) + val (map, surface) = fixture(child) + map.makeProjection = { { Point(900, 900) } } + + send(map, MotionEvent.ACTION_DOWN, 235f, 0) + send(map, MotionEvent.ACTION_UP, 235f, 10) + + assertEquals(View.INVISIBLE, map.markerViewAt(0).visibility) + assertTrue(child.actions.isEmpty()) + assertEquals(listOf(MotionEvent.ACTION_DOWN, MotionEvent.ACTION_UP), surface.actions) + } + + @Test + fun `the marker content follows the projection after the map moves on screen`() { + val child = View(context) + val (map, _) = fixture(child) + val marker = map.markerViewAt(0) as NitroMarkerContentView + child.layout(12, 8, 84, 36) + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + activity.setContentView(map) + map.layout(40, 60, 440, 460) + + fun assertChildAt( + projectedX: Int, + projectedY: Int, + ) { + val mapOnScreen = IntArray(2) + val childOnScreen = IntArray(2) + map.getLocationOnScreen(mapOnScreen) + child.getLocationOnScreen(childOnScreen) + assertEquals(projectedX + 12, childOnScreen[0] - mapOnScreen[0]) + assertEquals(projectedY + 8, childOnScreen[1] - mapOnScreen[1]) + } + + assertChildAt(150, 150) + marker.anchor = MarkerAnchor(0.5, 1.0) + map.makeProjection = { { Point(250, 280) } } + assertChildAt(200, 180) + } +} diff --git a/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerViewRenderRegistryTest.kt b/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerViewRenderRegistryTest.kt new file mode 100644 index 00000000..252ce2ae --- /dev/null +++ b/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerViewRenderRegistryTest.kt @@ -0,0 +1,119 @@ +package com.margelo.nitro.nitromaps + +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.LatLngBounds +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class MarkerViewRenderRegistryTest { + private fun cluster(count: Int = 2) = + ClusterElement.Cluster( + "grid", + LatLng(52.0, 21.0), + count, + listOf("b", "a"), + LatLngBounds(LatLng(51.99, 20.99), LatLng(52.01, 21.01)), + ) + + private fun live(id: String = "a") = ClusterElement.Single(marker(id = id, liveView = true)) + + private fun MarkerViewRenderRegistry.diff(vararg elements: ClusterElement) = + consume(computeMarkerRenderDiff(elements.toList(), displayedVersions(emptyMap()))) + + @Test + fun `live and SDK markers share one display set`() { + val registry = MarkerViewRenderRegistry() + var state: NativeMarkerViewRenderState? = null + registry.onChange = { state = it } + val sdkMarker = ClusterElement.Single(marker(id = "b")) + + val sdk = registry.diff(live(), sdkMarker) + + assertEquals(listOf(sdkMarker), sdk.added) + assertEquals(listOf("a"), state!!.markerIds.toList()) + assertEquals(mapOf(live().diffKey to live().renderVersion), registry.displayedVersions(emptyMap())) + } + + @Test + fun `collapsing into a JSX cluster unmounts its members and expanding mounts them again`() { + val registry = MarkerViewRenderRegistry().apply { setCustomClusters(true) } + var state: NativeMarkerViewRenderState? = null + registry.onChange = { state = it } + registry.diff(live()) + + val collapsed = registry.diff(cluster()) + assertTrue(collapsed.added.isEmpty()) + assertTrue(state!!.markerIds.isEmpty()) + assertEquals(listOf("a", "b"), state!!.clusters.single().markerIds.toList()) + + registry.diff(live()) + assertTrue(state!!.clusters.isEmpty()) + assertEquals(listOf("a"), state!!.markerIds.toList()) + } + + @Test + fun `a JSX cluster whose count changes is published again`() { + val registry = MarkerViewRenderRegistry().apply { setCustomClusters(true) } + var updates = 0 + registry.onChange = { updates += 1 } + registry.diff(cluster(count = 2)) + val before = updates + + registry.diff(cluster(count = 3)) + + assertEquals(before + 1, updates) + } + + @Test + fun `default cluster badges stay with the SDK`() { + val registry = MarkerViewRenderRegistry() + + val sdk = registry.diff(cluster()) + + assertEquals(listOf(cluster()), sdk.added) + assertTrue(registry.displayedVersions(emptyMap()).isEmpty()) + } + + @Test + fun `camera refreshes that leave the set unchanged do not reach JS`() { + val registry = MarkerViewRenderRegistry() + var updates = 0 + registry.onChange = { updates += 1 } + registry.diff(live()) + val before = updates + + repeat(120) { registry.diff(live()) } + // A live marker that moved is still the same mounted host; JS sent the move. + registry.diff(ClusterElement.Single(marker(id = "a", coordinate = Coordinate(1.0, 2.0), liveView = true))) + + assertEquals(before, updates) + registry.reset() + assertEquals(before + 1, updates) + assertTrue(registry.displayedVersions(emptyMap()).isEmpty()) + } + + @Test + fun `a marker that stops being live is added to the SDK`() { + val registry = MarkerViewRenderRegistry() + registry.diff(live()) + val sdkMarker = ClusterElement.Single(marker(id = "a")) + + val sdk = registry.diff(sdkMarker) + + assertEquals(listOf(sdkMarker), sdk.added) + assertTrue(registry.displayedVersions(emptyMap()).isEmpty()) + } + + @Test + fun `a marker that becomes live is removed from the SDK`() { + val registry = MarkerViewRenderRegistry() + val sdkMarker = ClusterElement.Single(marker(id = "a")) + val sdkVersions = mapOf(sdkMarker.diffKey to sdkMarker.renderVersion) + + val sdk = registry.consume(computeMarkerRenderDiff(listOf(live()), registry.displayedVersions(sdkVersions))) + + assertEquals(setOf(sdkMarker.diffKey), sdk.removedKeys) + assertTrue(sdk.added.isEmpty() && sdk.retained.isEmpty()) + } +} diff --git a/package/cpp/MarkerProjectionRegistry.cpp b/package/cpp/MarkerProjectionRegistry.cpp new file mode 100644 index 00000000..40078ce5 --- /dev/null +++ b/package/cpp/MarkerProjectionRegistry.cpp @@ -0,0 +1,44 @@ +#include "MarkerProjectionRegistry.hpp" + +#include +#include + +namespace margelo::nitro::nitromaps { +namespace { + std::mutex projectionMutex; + std::unordered_map projections; +} // namespace + +void publishMarkerProjection(int tag, float x, float y) { + if (tag <= 0) { + return; + } + std::lock_guard lock(projectionMutex); + projections[tag] = {.x = x, .y = y}; +} + +void clearMarkerProjection(int tag) { + if (tag <= 0) { + return; + } + std::lock_guard lock(projectionMutex); + projections.erase(tag); +} + +facebook::react::Point markerProjection(int tag) { + std::lock_guard lock(projectionMutex); + auto it = projections.find(tag); + return it == projections.end() ? facebook::react::Point{} : it->second; +} + +} // namespace margelo::nitro::nitromaps + +// Plain C entry points for Swift (`@_silgen_name`), which cannot call into a +// C++ namespace without importing React Native's headers into the module. +extern "C" void NitroMapsPublishMarkerProjection(int tag, float x, float y) { + margelo::nitro::nitromaps::publishMarkerProjection(tag, x, y); +} + +extern "C" void NitroMapsClearMarkerProjection(int tag) { + margelo::nitro::nitromaps::clearMarkerProjection(tag); +} diff --git a/package/cpp/MarkerProjectionRegistry.hpp b/package/cpp/MarkerProjectionRegistry.hpp new file mode 100644 index 00000000..34bc91dc --- /dev/null +++ b/package/cpp/MarkerProjectionRegistry.hpp @@ -0,0 +1,16 @@ +#pragma once + +#include + +namespace margelo::nitro::nitromaps { + +// Where each live marker host is translated to, in points, by Fabric tag. +// +// The native host writes on the UI thread whenever the camera moves it. Fabric +// reads while measuring, which can happen on another thread, so a shadow node +// must read here and never touch a UIView or an Android View. +void publishMarkerProjection(int tag, float x, float y); +void clearMarkerProjection(int tag); +facebook::react::Point markerProjection(int tag); + +} // namespace margelo::nitro::nitromaps diff --git a/package/cpp/README.md b/package/cpp/README.md index 37c6bf83..a6085a55 100644 --- a/package/cpp/README.md +++ b/package/cpp/README.md @@ -1,15 +1,7 @@ # C++ shared code -This directory will contain shared C++ code for cross-platform map logic once needed. +Code shared by the iOS and Android builds. -## Planned structure - -``` -cpp/ -├── HybridMapView.hpp -└── HybridMapView.cpp -``` - -Nitrogen can generate C++ bindings alongside Swift and Kotlin. Use C++ for shared geometry calculations, tile caching, or other hot-path code that benefits from a single implementation. +- `MarkerProjectionRegistry` — the screen translation of every live `MarkerView` host, written by the native host as the camera moves and read by the patched `HybridMarkerViewShadowNode` (see `scripts/patch-nitrogen-generated.mjs`) so Fabric measures a marker where it is drawn. See [src/native/README.md](../src/native/README.md) for the JS ↔ native architecture. diff --git a/package/ios/AppleMapProviderAdapter.swift b/package/ios/AppleMapProviderAdapter.swift index ccc31cd7..75504a1c 100644 --- a/package/ios/AppleMapProviderAdapter.swift +++ b/package/ios/AppleMapProviderAdapter.swift @@ -164,6 +164,27 @@ final class AppleMapProviderAdapter: MapProviderAdapter { } } + var customClusterViews: Bool? { + didSet { + overlayController.setCustomClusterViews(customClusterViews == true) + } + } + + var onMarkerViewRenderState: ((NativeMarkerViewRenderState) -> Void)? { + didSet { + overlayController.onMarkerViewRenderState = onMarkerViewRenderState + } + } + + var onCameraMoved: (() -> Void)? + + func makeMarkerProjection() -> MarkerProjection? { + let mapView = view + return { coordinate in + mapView.convert(coordinate.toCLLocationCoordinate2D(), toPointTo: mapView) + } + } + var mapPadding: EdgePadding? { didSet { applyMapPadding(to: view) @@ -504,10 +525,12 @@ final class AppleMapProviderAdapter: MapProviderAdapter { } func handleVisibleRegionChange() { + onCameraMoved?() regionChanges.cameraMoved() } func handleRegionDidChange() { + onCameraMoved?() stopLiveClustering() isRegionChanging = false cameraMoves.settleAll() @@ -623,6 +646,8 @@ final class AppleMapProviderAdapter: MapProviderAdapter { regionChanges.reset() isMapReady = false hasDeliveredMapReady = false + onCameraMoved = nil + onMarkerViewRenderState = nil onRegionChange = nil onRegionChangeComplete = nil onMapReady = nil @@ -655,6 +680,7 @@ final class AppleMapProviderAdapter: MapProviderAdapter { customMapStyle = nil googleMapId = nil clusteringEnabled = nil + customClusterViews = nil mapPadding = nil markerEnteringAnimation = nil clusterEnteringAnimation = nil diff --git a/package/ios/GoogleMapOverlayController.swift b/package/ios/GoogleMapOverlayController.swift index 691cc14d..a33f1091 100644 --- a/package/ios/GoogleMapOverlayController.swift +++ b/package/ios/GoogleMapOverlayController.swift @@ -38,6 +38,7 @@ final class GoogleMapOverlayController { private var polygonVersions: [String: ShapeRenderVersion] = [:] private var circleVersions: [String: ShapeRenderVersion] = [:] private let markerPipeline: MarkerRenderPipeline + private let markerViews = MarkerViewRenderRegistry() private let visualApplier = GoogleMarkerVisualApplier() var onMarkerPress: ((String) -> Void)? @@ -59,6 +60,26 @@ final class GoogleMapOverlayController { markerPipeline.usesViewportPipeline } + /// What the pipeline diffs against: the SDK markers and the live hosts. + private var displayedVersions: [String: Int] { + markerViews.displayedVersions(merging: markerVersions) + } + + var onMarkerViewRenderState: ((NativeMarkerViewRenderState) -> Void)? { + get { markerViews.onChange } + set { markerViews.onChange = newValue } + } + + /// Switching who draws clusters redraws every marker: a cluster on screen + /// belongs to the SDK or to JS, and the diff cannot move it between them. + func setCustomClusterViews(_ enabled: Bool) { + guard markerViews.setCustomClusters(enabled) else { + return + } + clearMarkers() + reapplyMarkers() + } + func reset() { markerPipeline.reset() clearMarkers() @@ -88,7 +109,7 @@ final class GoogleMapOverlayController { } markerPipeline.refreshNow( - displayedVersions: markerVersions, + displayedVersions: displayedVersions, region: mapView.currentNitroRegion().toMKCoordinateRegion(), viewSize: mapView.bounds.size, apply: { [weak self] diff in @@ -111,7 +132,7 @@ final class GoogleMapOverlayController { } markerPipeline.scheduleViewportRefresh( - displayedVersions: markerVersions, + displayedVersions: displayedVersions, region: mapView.currentNitroRegion().toMKCoordinateRegion(), viewSize: mapView.bounds.size, immediate: immediate, @@ -206,7 +227,7 @@ final class GoogleMapOverlayController { } markerPipeline.reapply( - displayedVersions: markerVersions, + displayedVersions: displayedVersions, region: mapView.currentNitroRegion().toMKCoordinateRegion(), viewSize: mapView.bounds.size, apply: { [weak self] diff in @@ -216,7 +237,7 @@ final class GoogleMapOverlayController { } private func applyDiff( - _ diff: MarkerRenderDiff, + _ pipelineDiff: MarkerRenderDiff, animateEntering: Bool = true, animationBudget: Int = maximumAnimatedMarkersPerDiff ) { @@ -224,6 +245,8 @@ final class GoogleMapOverlayController { return } + let diff = markerViews.consume(pipelineDiff) + for key in diff.removedKeys { markers.removeValue(forKey: key)?.map = nil markerVersions.removeValue(forKey: key) @@ -320,6 +343,7 @@ final class GoogleMapOverlayController { } private func clearMarkers() { + markerViews.reset() for marker in markers.values { marker.map = nil } diff --git a/package/ios/GoogleMapProviderAdapter.swift b/package/ios/GoogleMapProviderAdapter.swift index 2fd8b189..4c4feed8 100644 --- a/package/ios/GoogleMapProviderAdapter.swift +++ b/package/ios/GoogleMapProviderAdapter.swift @@ -175,6 +175,28 @@ final class GoogleMapProviderAdapter: NSObject, MapProviderAdapter { } } + var customClusterViews: Bool? { + didSet { + overlayController.setCustomClusterViews(customClusterViews == true) + } + } + + var onMarkerViewRenderState: ((NativeMarkerViewRenderState) -> Void)? { + didSet { + overlayController.onMarkerViewRenderState = onMarkerViewRenderState + } + } + + var onCameraMoved: (() -> Void)? + + /// One `GMSProjection` per camera frame, shared by every live marker. + func makeMarkerProjection() -> MarkerProjection? { + let projection = view.projection + return { coordinate in + projection.point(for: coordinate.toCLLocationCoordinate2D()) + } + } + var mapPadding: EdgePadding? { didSet { // Padding changes the camera that a region fit produces, so drop the @@ -333,6 +355,8 @@ final class GoogleMapProviderAdapter: NSObject, MapProviderAdapter { hasDeliveredMapReady = false view.delegate = nil settleProjectionsAwaitingFirstIdle(with: .failure(Self.releasedBeforeFirstIdleError())) + onCameraMoved = nil + onMarkerViewRenderState = nil overlayController.reset() onRegionChange = nil onRegionChangeComplete = nil @@ -365,6 +389,7 @@ final class GoogleMapProviderAdapter: NSObject, MapProviderAdapter { customMapStyle = nil googleMapId = nil clusteringEnabled = nil + customClusterViews = nil mapPadding = nil markerEnteringAnimation = nil clusterEnteringAnimation = nil @@ -652,11 +677,13 @@ extension GoogleMapProviderAdapter: GMSMapViewDelegate { } func mapView(_ mapView: GMSMapView, didChange position: GMSCameraPosition) { + onCameraMoved?() refreshGestureMarkersIfNeeded() regionChanges.cameraMoved() } func mapView(_ mapView: GMSMapView, idleAt position: GMSCameraPosition) { + onCameraMoved?() refreshVisibleMarkers() stopGestureMarkerRefresh() cameraMoves.settleAll() @@ -665,6 +692,9 @@ extension GoogleMapProviderAdapter: GMSMapViewDelegate { } func mapView(_ mapView: GMSMapView, didTapAt coordinate: CLLocationCoordinate2D) { + guard !mapView.touchStartedOnLiveMarker else { + return + } onPress?(Coordinate(latitude: coordinate.latitude, longitude: coordinate.longitude)) } @@ -674,6 +704,9 @@ extension GoogleMapProviderAdapter: GMSMapViewDelegate { name: String, location: CLLocationCoordinate2D ) { + guard !mapView.touchStartedOnLiveMarker else { + return + } onPoiPress?( NativePoiPressEvent( provider: .google, @@ -687,11 +720,18 @@ extension GoogleMapProviderAdapter: GMSMapViewDelegate { } func mapView(_ mapView: GMSMapView, didLongPressAt coordinate: CLLocationCoordinate2D) { + guard !mapView.touchStartedOnLiveMarker else { + return + } onLongPress?(Coordinate(latitude: coordinate.latitude, longitude: coordinate.longitude)) } func mapView(_ mapView: GMSMapView, didTap marker: GMSMarker) -> Bool { - overlayController.handleMarkerTap(marker) + // True also keeps the SDK from selecting the marker under a live one. + guard !mapView.touchStartedOnLiveMarker else { + return true + } + return overlayController.handleMarkerTap(marker) } func mapView(_ mapView: GMSMapView, didEndDragging marker: GMSMarker) { @@ -699,6 +739,9 @@ extension GoogleMapProviderAdapter: GMSMapViewDelegate { } func mapView(_ mapView: GMSMapView, didTap overlay: GMSOverlay) { + guard !mapView.touchStartedOnLiveMarker else { + return + } overlayController.handleOverlayTap(overlay) } diff --git a/package/ios/HybridMapView.swift b/package/ios/HybridMapView.swift index 6232bae1..11c76ae1 100644 --- a/package/ios/HybridMapView.swift +++ b/package/ios/HybridMapView.swift @@ -16,7 +16,7 @@ private struct AdapterConfiguration: Equatable { } final class HybridMapView: HybridMapViewSpec { - private let containerView = UIView() + private let containerView = NitroMapContainerView() private let lifecycleLock = NSLock() private let stateLock = NSLock() private let adapterSlot = ProviderAdapterSlot( @@ -128,6 +128,22 @@ final class HybridMapView: HybridMapViewSpec { } } + var customClusterViews: Bool? { + get { getBacked(\.customClusterViews) } + set { + setBackedOnMain(newValue, store: \.customClusterViews) { $0.customClusterViews = $1 } + } + } + + var onMarkerViewRenderState: ((NativeMarkerViewRenderState) -> Void)? { + get { getBacked(\.onMarkerViewRenderState) } + set { + setBackedOnMain(newValue, store: \.onMarkerViewRenderState) { + $0.onMarkerViewRenderState = $1 + } + } + } + var mapPadding: EdgePadding? { get { getBacked(\.mapPadding) } set { setBackedOnMain(newValue, store: \.mapPadding) { $0.mapPadding = $1 } } @@ -307,6 +323,7 @@ final class HybridMapView: HybridMapViewSpec { } recycleLifecycle() + detachMarkers() adapterSlot.release() withStateLock { self._state = MapViewState() @@ -376,9 +393,27 @@ final class HybridMapView: HybridMapViewSpec { } attach(contentView: nextAdapter.contentView) + attachMarkers(to: nextAdapter) syncState(to: nextAdapter) } + /// Live markers move into the adapter's map view and follow its camera. + private func attachMarkers(to adapter: MapProviderAdapter) { + containerView.mapSurface = adapter.contentView + containerView.makeProjection = { [weak adapter] in + adapter?.makeMarkerProjection() + } + adapter.onCameraMoved = { [weak containerView] in + containerView?.updateMarkerPositions() + } + } + + /// Takes the live markers back out of the map view about to go away. + private func detachMarkers() { + containerView.makeProjection = nil + containerView.mapSurface = nil + } + private static func makeAdapter(for configuration: AdapterConfiguration) -> MapProviderAdapter { switch configuration.provider { case .apple: @@ -404,7 +439,8 @@ final class HybridMapView: HybridMapViewSpec { private func attach(contentView: UIView) { contentView.translatesAutoresizingMaskIntoConstraints = false - containerView.addSubview(contentView) + // Below any live marker still waiting for a map view to move into. + containerView.insertSubview(contentView, at: 0) NSLayoutConstraint.activate([ contentView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), contentView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), diff --git a/package/ios/HybridMapViewDelegate.swift b/package/ios/HybridMapViewDelegate.swift index 7e68e233..bc20c86c 100644 --- a/package/ios/HybridMapViewDelegate.swift +++ b/package/ios/HybridMapViewDelegate.swift @@ -68,6 +68,18 @@ final class HybridMapViewDelegate: NSObject, MKMapViewDelegate, UIGestureRecogni true } + /// A touch on a live marker belongs to its JSX. Turned away before + /// recognition: a recognized map tap would cancel the marker's own touches. + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldReceive touch: UITouch + ) -> Bool { + guard let container = parent?.view.superview as? NitroMapContainerView else { + return true + } + return !container.containsMarker(at: touch.location(in: container)) + } + private func isAnnotationView(at point: CGPoint, in mapView: MKMapView) -> Bool { var view: UIView? = mapView.hitTest(point, with: nil) while let current = view { @@ -167,6 +179,12 @@ final class HybridMapViewDelegate: NSObject, MKMapViewDelegate, UIGestureRecogni } func mapView(_ mapView: MKMapView, didSelect view: MKAnnotationView) { + // MapKit selects whatever is under a tap, including beneath a live marker. + if mapView.touchStartedOnLiveMarker, let annotation = view.annotation { + mapView.deselectAnnotation(annotation, animated: false) + return + } + if #available(iOS 16.0, *), let mapFeature = view.annotation as? MKMapFeatureAnnotation, handleMapFeatureSelection(mapFeature, in: mapView) @@ -196,6 +214,11 @@ final class HybridMapViewDelegate: NSObject, MKMapViewDelegate, UIGestureRecogni } func mapView(_ mapView: MKMapView, didSelect annotation: MKAnnotation) { + if mapView.touchStartedOnLiveMarker { + mapView.deselectAnnotation(annotation, animated: false) + return + } + if #available(iOS 16.0, *), let mapFeature = annotation as? MKMapFeatureAnnotation { diff --git a/package/ios/HybridMarkerView.swift b/package/ios/HybridMarkerView.swift new file mode 100644 index 00000000..30a7d146 --- /dev/null +++ b/package/ios/HybridMarkerView.swift @@ -0,0 +1,22 @@ +import UIKit + +/// Native host of a live `MarkerView`. The map it is mounted in positions it. +final class HybridMarkerView: HybridMarkerViewSpec { + private let content = NitroMarkerContentView() + + var view: UIView { + content + } + + var coordinate = Coordinate(latitude: 0, longitude: 0) { + didSet { content.coordinate = coordinate } + } + + var anchor: MarkerAnchor? { + didSet { content.anchor = anchor } + } + + func afterUpdate() { + content.updatePosition(projection: content.container?.makeProjection?()) + } +} diff --git a/package/ios/MapOverlayController.swift b/package/ios/MapOverlayController.swift index 96bbb636..69086c6c 100644 --- a/package/ios/MapOverlayController.swift +++ b/package/ios/MapOverlayController.swift @@ -35,6 +35,7 @@ final class MapOverlayController { private var displayedAnnotations: [String: MKAnnotation] = [:] private var displayedAnnotationVersions: [String: Int] = [:] private let markerPipeline = MarkerRenderPipeline() + private let markerViews = MarkerViewRenderRegistry() private let polylineLayer = ShapeLayer() private let polygonLayer = ShapeLayer() private let circleLayer = ShapeLayer() @@ -51,6 +52,29 @@ final class MapOverlayController { markerPipeline.usesViewportPipeline } + /// What the pipeline diffs against: the annotations and the live hosts. + private var displayedVersions: [String: Int] { + markerViews.displayedVersions(merging: displayedAnnotationVersions) + } + + var onMarkerViewRenderState: ((NativeMarkerViewRenderState) -> Void)? { + get { markerViews.onChange } + set { markerViews.onChange = newValue } + } + + /// Switching who draws clusters redraws every marker: a cluster on screen + /// belongs to MapKit or to JS, and the diff cannot move it between them. + func setCustomClusterViews(_ enabled: Bool) { + guard markerViews.setCustomClusters(enabled) else { + return + } + mapView?.removeAnnotations(Array(displayedAnnotations.values)) + displayedAnnotations.removeAll() + displayedAnnotationVersions.removeAll() + markerViews.reset() + reapplyMarkers() + } + func setClusteringEnabled(_ enabled: Bool) { guard markerPipeline.setClusteringEnabled(enabled) else { return @@ -60,6 +84,7 @@ final class MapOverlayController { func reset() { markerPipeline.reset() + markerViews.reset() guard let mapView else { return } @@ -88,7 +113,7 @@ final class MapOverlayController { } markerPipeline.reapply( - displayedVersions: displayedAnnotationVersions, + displayedVersions: displayedVersions, region: mapView.region, viewSize: mapView.bounds.size, apply: { [weak self] diff in @@ -103,7 +128,7 @@ final class MapOverlayController { return } markerPipeline.refreshNow( - displayedVersions: displayedAnnotationVersions, + displayedVersions: displayedVersions, region: mapView.region, viewSize: mapView.bounds.size, apply: { [weak self] diff in @@ -119,7 +144,7 @@ final class MapOverlayController { } markerPipeline.scheduleViewportRefresh( - displayedVersions: displayedAnnotationVersions, + displayedVersions: displayedVersions, region: mapView.region, viewSize: mapView.bounds.size, immediate: immediate, @@ -129,11 +154,13 @@ final class MapOverlayController { ) } - private func applyDiff(_ diff: MarkerRenderDiff) { + private func applyDiff(_ pipelineDiff: MarkerRenderDiff) { guard let mapView else { return } + let diff = markerViews.consume(pipelineDiff) + if !diff.removedKeys.isEmpty { let removed = diff.removedKeys.compactMap { key in displayedAnnotationVersions.removeValue(forKey: key) diff --git a/package/ios/MapProviderAdapter.swift b/package/ios/MapProviderAdapter.swift index d9d85d3c..5adce044 100644 --- a/package/ios/MapProviderAdapter.swift +++ b/package/ios/MapProviderAdapter.swift @@ -4,6 +4,12 @@ import UIKit protocol MapProviderAdapter: AnyObject { var contentView: UIView { get } + /// Called on every camera frame, gestures and animations alike, on the main thread. + var onCameraMoved: (() -> Void)? { get set } + + /// A projection for the current camera, or nil while there is no map to project with. + func makeMarkerProjection() -> MarkerProjection? + var mapType: MapType { get set } var region: Region? { get set } var camera: Camera? { get set } @@ -19,6 +25,8 @@ protocol MapProviderAdapter: AnyObject { var customMapStyle: String? { get set } var googleMapId: String? { get set } var clusteringEnabled: Bool? { get set } + var customClusterViews: Bool? { get set } + var onMarkerViewRenderState: ((NativeMarkerViewRenderState) -> Void)? { get set } var mapPadding: EdgePadding? { get set } var markerEnteringAnimation: OverlayEnteringAnimationDescriptor? { get set } var clusterEnteringAnimation: OverlayEnteringAnimationDescriptor? { get set } @@ -77,6 +85,8 @@ final class UnavailableMapProviderAdapter: MapProviderAdapter { let contentView: UIView private let error: Error + var onCameraMoved: (() -> Void)? + var mapType: MapType = .standard var region: Region? var camera: Camera? @@ -92,6 +102,8 @@ final class UnavailableMapProviderAdapter: MapProviderAdapter { var customMapStyle: String? var googleMapId: String? var clusteringEnabled: Bool? + var customClusterViews: Bool? + var onMarkerViewRenderState: ((NativeMarkerViewRenderState) -> Void)? var mapPadding: EdgePadding? var markerEnteringAnimation: OverlayEnteringAnimationDescriptor? var clusterEnteringAnimation: OverlayEnteringAnimationDescriptor? @@ -175,5 +187,9 @@ final class UnavailableMapProviderAdapter: MapProviderAdapter { Promise.rejected(withError: error) } + func makeMarkerProjection() -> MarkerProjection? { + nil + } + func prepareForRecycle() {} } diff --git a/package/ios/MapViewState.swift b/package/ios/MapViewState.swift index 919cfbf4..a8139297 100644 --- a/package/ios/MapViewState.swift +++ b/package/ios/MapViewState.swift @@ -17,6 +17,8 @@ struct MapViewState { var customMapStyle: String? var googleMapId: String? var clusteringEnabled: Bool? + var customClusterViews: Bool? + var onMarkerViewRenderState: ((NativeMarkerViewRenderState) -> Void)? var mapPadding: EdgePadding? var markerEnteringAnimation: OverlayEnteringAnimationDescriptor? var clusterEnteringAnimation: OverlayEnteringAnimationDescriptor? @@ -52,6 +54,9 @@ struct MapViewState { adapter.applePoiDetailPresentation = applePoiDetailPresentation adapter.customMapStyle = customMapStyle adapter.googleMapId = googleMapId + // Before the markers: the first diff must already know what is live. + adapter.customClusterViews = customClusterViews + adapter.onMarkerViewRenderState = onMarkerViewRenderState adapter.clusteringEnabled = clusteringEnabled adapter.mapPadding = mapPadding adapter.markerEnteringAnimation = markerEnteringAnimation diff --git a/package/ios/MarkerDescriptor+Fingerprint.swift b/package/ios/MarkerDescriptor+Fingerprint.swift index 26ebe044..341285d3 100644 --- a/package/ios/MarkerDescriptor+Fingerprint.swift +++ b/package/ios/MarkerDescriptor+Fingerprint.swift @@ -8,6 +8,7 @@ extension MarkerDescriptor { hasher.combine(subtitle) hasher.combine(draggable) hasher.combine(clusterable) + hasher.combine(liveView) hasher.combine(image?.uri) hasher.combine(image?.width) hasher.combine(image?.height) diff --git a/package/ios/MarkerViewRenderRegistry.swift b/package/ios/MarkerViewRenderRegistry.swift new file mode 100644 index 00000000..545f9b6d --- /dev/null +++ b/package/ios/MarkerViewRenderRegistry.swift @@ -0,0 +1,140 @@ +import MapKit + +/// Splits the marker pipeline's output between the SDK and live `MarkerView` +/// hosts. Live elements are taken out of every diff before it reaches the SDK, +/// and JS hears which live hosts to mount whenever that set changes. +/// +/// `MarkerViewRenderRegistry.kt` is the Android counterpart; change the two together. +final class MarkerViewRenderRegistry { + /// Whether clusters are live hosts (`renderCluster`) rather than SDK badges. + private(set) var customClusters = false + + var onChange: ((NativeMarkerViewRenderState) -> Void)? { + didSet { publish() } + } + + private var entries: [String: MarkerRenderEntry] = [:] + + /// `sdkVersions` plus the live elements, which count as displayed too: the + /// pipeline diffs against both. + func displayedVersions(merging sdkVersions: [String: Int]) -> [String: Int] { + guard !entries.isEmpty else { + return sdkVersions + } + var versions = sdkVersions + for (key, entry) in entries { + versions[key] = entry.version + } + return versions + } + + /// Returns whether the setting changed; the caller then redraws every marker. + func setCustomClusters(_ enabled: Bool) -> Bool { + guard customClusters != enabled else { + return false + } + customClusters = enabled + return true + } + + func reset() { + guard !entries.isEmpty else { + return + } + entries.removeAll() + publish() + } + + /// Takes the live elements out of `diff` and returns what the SDK still has to do. + func consume(_ diff: MarkerRenderDiff) -> MarkerRenderDiff { + var removed = diff.removedKeys + var added: [MarkerRenderEntry] = [] + var retained: [MarkerRenderEntry] = [] + var changed = false + + for key in diff.removedKeys where entries.removeValue(forKey: key) != nil { + changed = true + } + + func route(_ entry: MarkerRenderEntry, isNew: Bool) { + if isLive(entry.element) { + let previous = entries.updateValue(entry, forKey: entry.key) + if previous == nil { + // Retained but not live until now: the SDK drew it, so it goes. + if !isNew { + removed.insert(entry.key) + } + changed = true + } else if case .cluster = entry.element { + // A cluster's position, count and members come from here; a live + // marker's own props come from JS, which need not hear about them. + changed = true + } + } else if entries.removeValue(forKey: entry.key) != nil { + // Live until now, so the SDK has nothing to update: it adds it. + added.append(entry) + changed = true + } else if isNew { + added.append(entry) + } else { + retained.append(entry) + } + } + + for entry in diff.added { + route(entry, isNew: true) + } + for entry in diff.retained { + route(entry, isNew: false) + } + + if changed { + publish() + } + return MarkerRenderDiff(removedKeys: removed, added: added, retained: retained) + } + + private func isLive(_ element: MarkerClusterEngine.Element) -> Bool { + switch element { + case .single(let descriptor): + return descriptor.liveView == true + case .cluster: + return customClusters + } + } + + private func publish() { + guard let onChange else { + return + } + + var markerIds: [String] = [] + var clusters: [NativeMarkerViewCluster] = [] + for key in entries.keys.sorted() { + switch entries[key]?.element { + case .single(let descriptor): + markerIds.append(descriptor.id) + case .cluster(_, let coordinate, _, let memberIds, _): + clusters.append( + NativeMarkerViewCluster( + id: key, + coordinate: Coordinate( + latitude: coordinate.latitude, + longitude: Self.wrappedLongitude(coordinate.longitude) + ), + markerIds: memberIds.sorted() + ) + ) + case .none: + continue + } + } + onChange(NativeMarkerViewRenderState(markerIds: markerIds, clusters: clusters)) + } + + /// Cluster centers across the antimeridian can fall outside -180...180. + private static func wrappedLongitude(_ longitude: Double) -> Double { + let wrapped = (longitude + 180).truncatingRemainder(dividingBy: 360) + return (wrapped < 0 ? wrapped + 360 : wrapped) - 180 + } +} diff --git a/package/ios/NitroMapContainerView.swift b/package/ios/NitroMapContainerView.swift new file mode 100644 index 00000000..334e628d --- /dev/null +++ b/package/ios/NitroMapContainerView.swift @@ -0,0 +1,176 @@ +import UIKit + +/// Projects coordinates for one pass over the live markers. Built once per +/// camera frame, so a provider can reuse one projection snapshot for every host. +typealias MarkerProjection = (Coordinate) -> CGPoint? + +/// The view `HybridMapView` hands to Fabric. It holds the provider's map view +/// and the live `MarkerView` hosts that Fabric mounts into the map. +/// +/// Hosts go inside the view that owns the SDK's pan recognizer - for MapKit an +/// internal content view, not `MKMapView` itself - so a drag that starts on a +/// marker still pans the map. They stay in Fabric's hierarchy: nothing is +/// reparented into SDK annotation views or snapshotted. +final class NitroMapContainerView: UIView { + /// Returns the projection for the current camera, or nil while the provider + /// has no map to project with. + var makeProjection: (() -> MarkerProjection?)? { + didSet { updateMarkerPositions() } + } + + private var markerHosts: [MarkerHost] = [] + private var lastTouchStartedOnMarker = false + private weak var resolvedHostParent: UIView? + + /// Where the hosts are subviews: the SDK's gesture-owning view, else the map + /// view, else this view while there is no map. + private var hostParent: UIView { + resolvedHostParent ?? mapSurface ?? self + } + + /// Where the map view's origin is in `hostParent`. Projections are in map + /// view coordinates; the host transform is in `hostParent`'s. + private(set) var hostOffset: CGPoint = .zero + + /// The provider's map view. Hosts mounted before it exists, or kept across a + /// provider switch, move into the new one with their React state intact. + weak var mapSurface: UIView? { + didSet { + guard oldValue !== mapSurface else { + return + } + resolveHostParent() + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + clipsToBounds = true + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } + + /// Whether the touch sequence in progress began on a live marker. Map taps, + /// POI selection and SDK marker taps beneath a live marker check it, since + /// the SDK's own recognizers see every touch inside the map view. + var touchStartedOnMarker: Bool { + lastTouchStartedOnMarker + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let hit = super.hitTest(point, with: event) + if event?.type == .touches { + lastTouchStartedOnMarker = containsMarker(at: point) + } + return hit + } + + @objc(nitroMountChild:atIndex:) + func mountFabricChild(_ child: UIView, at index: Int) { + guard let content = child.subviews.lazy.compactMap({ $0 as? NitroMarkerContentView }).first else { + // Only MarkerView hosts are rendered into a MapView. + return + } + if markerHosts.indices.contains(index) { + hostParent.insertSubview(child, belowSubview: markerHosts[index].view) + } else { + hostParent.addSubview(child) + } + let host = MarkerHost(view: child, content: content) + content.host = child + content.container = self + markerHosts.insert(host, at: min(index, markerHosts.count)) + content.updatePosition(projection: makeProjection?()) + } + + @objc(nitroUnmountChild:) + func unmountFabricChild(_ child: UIView) { + if let index = markerHosts.firstIndex(where: { $0.view === child }) { + let host = markerHosts.remove(at: index) + host.content.detach() + } + child.removeFromSuperview() + } + + /// Whether `point`, in this view's coordinates, is on a visible live marker. + func containsMarker(at point: CGPoint) -> Bool { + let parent = hostParent + let parentPoint = parent === self ? point : convert(point, to: parent) + return markerHosts.contains { host in + !host.view.isHidden && host.view.frame.contains(parentPoint) + } + } + + override func layoutSubviews() { + super.layoutSubviews() + if let mapSurface, resolvedHostParent == nil || hostOffset != Self.offset(of: mapSurface, in: hostParent) { + // The SDK may build its gesture-owning view or lay it out late. + resolveHostParent() + } else { + updateMarkerPositions() + } + } + + private func resolveHostParent() { + let parent = mapSurface.map(Self.gestureOwner(in:)) ?? self + resolvedHostParent = parent === self ? nil : parent + hostOffset = mapSurface.map { Self.offset(of: $0, in: parent) } ?? .zero + for host in markerHosts where host.view.superview !== parent { + parent.addSubview(host.view) + } + updateMarkerPositions() + } + + private static func offset(of surface: UIView, in parent: UIView) -> CGPoint { + parent === surface ? .zero : surface.convert(CGPoint.zero, to: parent) + } + + /// The first view, breadth first from `surface`, whose recognizers pan the + /// map. MapKit keeps them on a content view inside `MKMapView`; a host that + /// is not inside that view would swallow every drag that starts on it. + private static func gestureOwner(in surface: UIView) -> UIView { + var level: [UIView] = [surface] + for _ in 0..<4 { + if let owner = level.first(where: { view in + view.gestureRecognizers?.contains { $0 is UIPanGestureRecognizer } == true + }) { + return owner + } + level = level.flatMap(\.subviews) + } + return surface + } + + /// Repositions every host for the current camera. Called on every camera + /// frame: one projection, a transform per host that moved, no JS. + func updateMarkerPositions() { + guard !markerHosts.isEmpty else { + return + } + let projection = makeProjection?() + UIView.performWithoutAnimation { + for host in markerHosts { + host.content.updatePosition(projection: projection) + } + } + } +} + +private struct MarkerHost { + /// The Fabric component view; the one that moves. + let view: UIView + /// Its content view, which owns the marker's coordinate and anchor. + let content: NitroMarkerContentView +} + +extension UIView { + /// Whether the touch in progress began on a live marker drawn over this map + /// view. The SDK sees that touch too; a tap meant for the marker must not + /// also press the map, a POI or an SDK marker beneath it. + var touchStartedOnLiveMarker: Bool { + (superview as? NitroMapContainerView)?.touchStartedOnMarker == true + } +} diff --git a/package/ios/NitroMarkerContentView.swift b/package/ios/NitroMarkerContentView.swift new file mode 100644 index 00000000..26671ba4 --- /dev/null +++ b/package/ios/NitroMarkerContentView.swift @@ -0,0 +1,126 @@ +import UIKit + +@_silgen_name("NitroMapsPublishMarkerProjection") +private func publishMarkerProjection(_ tag: Int32, _ x: Float, _ y: Float) + +@_silgen_name("NitroMapsClearMarkerProjection") +private func clearMarkerProjection(_ tag: Int32) + +/// Content view of a live `MarkerView`. Fabric mounts the marker's JSX here and +/// keeps owning it; only the outer component view is moved, by a transform. +final class NitroMarkerContentView: UIView { + var coordinate: Coordinate? + var anchor: MarkerAnchor? + + /// The Fabric component view this content belongs to; set while mounted in a map. + weak var host: UIView? + weak var container: NitroMapContainerView? + + private var publishedTag: Int32 = 0 + private var publishedOrigin = CGPoint(x: CGFloat.nan, y: CGFloat.nan) + + override init(frame: CGRect) { + super.init(frame: frame) + clipsToBounds = true + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } + + @objc(nitroMountChild:atIndex:) + func mountFabricChild(_ child: UIView, at index: Int) { + insertSubview(child, at: index) + } + + @objc(nitroUnmountChild:) + func unmountFabricChild(_ child: UIView) { + child.removeFromSuperview() + } + + override func layoutSubviews() { + super.layoutSubviews() + // Fabric resized the host; its anchor point moved with it. + updatePosition(projection: container?.makeProjection?()) + } + + /// Places the host for `projection`, the camera's current one. A marker off + /// screen is hidden, which also takes it out of hit-testing and drawing. + func updatePosition(projection: MarkerProjection?) { + guard let host, let container else { + return + } + let size = host.bounds.size + guard let coordinate, + let point = projection?(coordinate), + point.x.isFinite, point.y.isFinite, + size.width > 0, size.height > 0 + else { + setHidden(true, host: host) + return + } + + // Snapped to the pixel grid, as MapKit does for annotation views: a + // fractional origin resamples the layer and blurs text in the marker. + let scale = max(1, traitCollection.displayScale) + let origin = CGPoint( + x: ((point.x - size.width * CGFloat(anchor?.x ?? 0.5)) * scale).rounded() / scale, + y: ((point.y - size.height * CGFloat(anchor?.y ?? 1)) * scale).rounded() / scale + ) + let isOnScreen = CGRect(origin: origin, size: size).intersects(container.bounds) + setHidden(!isOnScreen, host: host) + guard isOnScreen else { + return + } + + let offset = container.hostOffset + let transform = CGAffineTransform(translationX: origin.x + offset.x, y: origin.y + offset.y) + if host.transform != transform { + host.transform = transform + } + publish(origin, tag: Int32(truncatingIfNeeded: host.tag)) + } + + /// Unmounted from the map: forget the projection and leave the component + /// view as Fabric laid it out. + func detach() { + clearPublishedProjection() + host?.transform = .identity + host?.isHidden = false + host = nil + container = nil + } + + private func setHidden(_ hidden: Bool, host: UIView) { + if host.isHidden != hidden { + host.isHidden = hidden + } + if hidden { + clearPublishedProjection() + } + } + + /// Hands the translation to the shadow node, so `measure()` reports the + /// marker where it is drawn. Written only when it changed. + private func publish(_ origin: CGPoint, tag: Int32) { + guard tag > 0, tag != publishedTag || origin != publishedOrigin else { + return + } + if publishedTag > 0 && publishedTag != tag { + clearMarkerProjection(publishedTag) + } + publishMarkerProjection(tag, Float(origin.x), Float(origin.y)) + publishedTag = tag + publishedOrigin = origin + } + + private func clearPublishedProjection() { + guard publishedTag > 0 else { + return + } + clearMarkerProjection(publishedTag) + publishedTag = 0 + publishedOrigin = CGPoint(x: CGFloat.nan, y: CGFloat.nan) + } +} diff --git a/package/nitro.json b/package/nitro.json index b86f88ea..0a4b7644 100644 --- a/package/nitro.json +++ b/package/nitro.json @@ -1,11 +1,15 @@ { "$schema": "https://nitro.margelo.com/nitro.schema.json", - "cxxNamespace": ["nitromaps"], + "cxxNamespace": [ + "nitromaps" + ], "ios": { "iosModuleName": "NitroMaps" }, "android": { - "androidNamespace": ["nitromaps"], + "androidNamespace": [ + "nitromaps" + ], "androidCxxLibName": "NitroMaps" }, "autolinking": { @@ -18,6 +22,16 @@ "language": "kotlin", "implementationClassName": "HybridMapView" } + }, + "MarkerView": { + "ios": { + "language": "swift", + "implementationClassName": "HybridMarkerView" + }, + "android": { + "language": "kotlin", + "implementationClassName": "HybridMarkerView" + } } } } diff --git a/package/react-native-better-maps.podspec b/package/react-native-better-maps.podspec index fdb0dd29..ef17ecfd 100644 --- a/package/react-native-better-maps.podspec +++ b/package/react-native-better-maps.podspec @@ -48,6 +48,9 @@ Pod::Spec.new do |s| 'ios/**/*.{m,mm}', 'cpp/**/*.{hpp,cpp}', ] + # The C++ headers include React Native's; they must stay out of the Swift + # module's umbrella header. + s.private_header_files = ['cpp/**/*.{h,hpp}'] s.exclude_files = [ 'ios/Package.swift', 'ios/Tests/**/*', diff --git a/package/scripts/patch-nitrogen-generated.mjs b/package/scripts/patch-nitrogen-generated.mjs index feaa8e75..bedae203 100644 --- a/package/scripts/patch-nitrogen-generated.mjs +++ b/package/scripts/patch-nitrogen-generated.mjs @@ -11,11 +11,13 @@ function replaceOnce(filePath, from, to) { const fromCount = source.split(from).length - 1; const toCount = source.split(to).length - 1; - if (fromCount === 0 && toCount === 1) { + // Already patched. `to` may itself contain `from` (an inserted line after + // an anchor), so only the occurrences outside `to` count as unpatched. + if (toCount === 1 && fromCount === to.split(from).length - 1) { return; } - if (fromCount !== 1) { + if (fromCount !== 1 || toCount !== 0) { throw new Error( `Expected exactly one patch target in ${filePath}, found ${fromCount}`, ); @@ -24,29 +26,69 @@ function replaceOnce(filePath, from, to) { writeFileSync(filePath, source.replace(from, to)); } -replaceOnce( - join( - packageDir, - 'nitrogen/generated/shared/c++/views/HybridMapViewComponent.cpp', - ), - ` const std::shared_ptr& constProps = concreteShadowNode.getConcreteSharedProps(); - const std::shared_ptr& props = std::const_pointer_cast(constProps); +for (const name of ['MapView', 'MarkerView']) { + replaceOnce( + join( + packageDir, + `nitrogen/generated/shared/c++/views/Hybrid${name}Component.cpp`, + ), + ` const std::shared_ptr& constProps = concreteShadowNode.getConcreteSharedProps(); + const std::shared_ptr& props = std::const_pointer_cast(constProps); `, - ` auto constProps = std::static_pointer_cast(concreteShadowNode.getProps()); - auto props = std::const_pointer_cast(constProps); + ` auto constProps = std::static_pointer_cast(concreteShadowNode.getProps()); + auto props = std::const_pointer_cast(constProps); `, -); + ); -replaceOnce( - join( - packageDir, - 'nitrogen/generated/shared/c++/views/HybridMapViewComponent.hpp', - ), - ` HybridMapViewState(const HybridMapViewState& /* previousState */, folly::dynamic /* data */) {} + replaceOnce( + join( + packageDir, + `nitrogen/generated/shared/c++/views/Hybrid${name}Component.hpp`, + ), + ` Hybrid${name}State(const Hybrid${name}State& /* previousState */, folly::dynamic /* data */) {} `, - ` HybridMapViewState(const HybridMapViewState& previousState, folly::dynamic /* data */): + ` Hybrid${name}State(const Hybrid${name}State& previousState, folly::dynamic /* data */): _props(previousState.getProps()) {} `, + ); +} + +// A live marker is placed by a native transform that Yoga never sees. Its +// shadow node reports the same translation, read from the projection registry +// the native host publishes to, so `measure()` - and with it `Pressable`'s +// press-retention rect - sees the marker where the finger does. No JS and no +// Yoga commit per camera frame. +const markerViewComponentHeader = join( + packageDir, + 'nitrogen/generated/shared/c++/views/HybridMarkerViewComponent.hpp', +); +replaceOnce( + markerViewComponentHeader, + '#include \n', + '#include \n#include "MarkerProjectionRegistry.hpp"\n', +); +replaceOnce( + markerViewComponentHeader, + ` using HybridMarkerViewShadowNode = react::ConcreteViewShadowNode;`, + ` class HybridMarkerViewShadowNode final: public react::ConcreteViewShadowNode { + public: + using BaseShadowNode = react::ConcreteViewShadowNode; + using BaseShadowNode::BaseShadowNode; + + react::Transform getTransform() const override { + const auto offset = markerProjection(getTag()); + return react::Transform::Translate(offset.x, offset.y, 0) * BaseShadowNode::getTransform(); + } + };`, ); for (const fileName of [ @@ -94,3 +136,106 @@ replaceOnce( 'coordinates: coordinates.map({ __item in __item })', 'coordinates: (0.. *)child index:(NSInteger)index { + [self.contentView nitroMountChild:child atIndex:index]; +} + +- (void)unmountChildComponentView:(UIView *)child index:(NSInteger)index { + [self.contentView nitroUnmountChild:child]; +} + +- (void) updateView {`, + ); +} + +// Fabric on Android mounts children only through a ViewGroupManager. MapView +// keeps the SDK view out of the child indices Fabric sees. +for (const name of ['MapView', 'MarkerView']) { + const file = join( + packageDir, + `nitrogen/generated/android/kotlin/com/margelo/nitro/nitromaps/views/Hybrid${name}Manager.kt`, + ); + replaceOnce( + file, + 'import android.view.View\n', + 'import android.view.View\nimport android.view.ViewGroup\n', + ); + replaceOnce( + file, + 'import com.facebook.react.uimanager.SimpleViewManager\n', + 'import com.facebook.react.uimanager.ViewGroupManager\n', + ); + replaceOnce( + file, + `public class Hybrid${name}Manager: SimpleViewManager() {`, + `public class Hybrid${name}Manager: ViewGroupManager() {`, + ); + replaceOnce( + file, + 'override fun createViewInstance(reactContext: ThemedReactContext): View {', + 'override fun createViewInstance(reactContext: ThemedReactContext): ViewGroup {', + ); + replaceOnce( + file, + 'override fun updateState(view: View, props: ReactStylesDiffMap, stateWrapper: StateWrapper): Any? {', + 'override fun updateState(view: ViewGroup, props: ReactStylesDiffMap, stateWrapper: StateWrapper): Any? {', + ); + replaceOnce( + file, + 'override fun onDropViewInstance(view: View) {', + 'override fun onDropViewInstance(view: ViewGroup) {', + ); + replaceOnce( + file, + 'protected override fun prepareToRecycleView(reactContext: ThemedReactContext, view: View): View? {', + 'protected override fun prepareToRecycleView(reactContext: ThemedReactContext, view: ViewGroup): ViewGroup? {', + ); +} + +replaceOnce( + join( + packageDir, + 'nitrogen/generated/android/kotlin/com/margelo/nitro/nitromaps/views/HybridMapViewManager.kt', + ), + ' override fun getName(): String {', + ` override fun addView(parent: ViewGroup, child: View, index: Int) { + (parent as NitroMapContainerView).addMarkerView(child, index) + } + + override fun getChildCount(parent: ViewGroup): Int = (parent as NitroMapContainerView).markerViewCount + + override fun getChildAt(parent: ViewGroup, index: Int): View = (parent as NitroMapContainerView).markerViewAt(index) + + override fun removeViewAt(parent: ViewGroup, index: Int) { + (parent as NitroMapContainerView).removeMarkerViewAt(index) + } + + override fun removeAllViews(parent: ViewGroup) { + (parent as NitroMapContainerView).removeAllMarkerViews() + } + + override fun getName(): String {`, +); diff --git a/package/src/components/MapView.tsx b/package/src/components/MapView.tsx index 4ab2f588..b1599a80 100644 --- a/package/src/components/MapView.tsx +++ b/package/src/components/MapView.tsx @@ -1,7 +1,10 @@ import { + cloneElement, useCallback, useImperativeHandle, useMemo, + useState, + type ReactElement, type Ref, } from 'react'; import { runWithValidCamera } from '../camera/runWithValidCamera'; @@ -13,8 +16,15 @@ import { useStableValue } from '../hooks/useStableValue'; import { NativeMapView } from '../native/MapViewNative'; import type { MapView as NativeMapViewHybrid, + NativeMarkerViewCluster, + NativeMarkerViewRenderState, NativePoiPressEvent, } from '../native/specs/MapView.nitro'; +import type { MarkerDescriptor } from '../native/specs/overlays'; +import { + isMarkerViewElement, + markerViewPropsProblem, +} from '../overlays/collectMarkerView'; import { circleListsEqual, enteringAnimationsEqual, @@ -23,7 +33,13 @@ import { polylineListsEqual, } from '../overlays/descriptorEquality'; import { OverlayType, overlayCallbackKey } from '../overlays/overlayType'; +import { + clusterMemberCoordinates, + markerViewRenderStatesEqual, +} from '../overlays/markerViewRenderState'; import { normalizeMarkerDescriptors } from '../overlays/normalizeMarkerDescriptors'; +import type { CollectedMarkerView } from '../overlays/overlayCollect'; +import { warnOverlay } from '../overlays/warnOverlay'; import { normalizeCircleDescriptors, normalizePolygonDescriptors, @@ -40,6 +56,43 @@ import type { Coordinate } from '../types/coordinate'; import type { MapViewProps, PoiPressEvent } from '../types/map'; import type { MapViewRef } from '../types/ref'; import { normalizeEnteringAnimation } from '../utils/enteringAnimation'; +import { MarkerViewHostContext } from './MarkerViewHostContext'; + +const CLUSTER_FIT_PADDING = { top: 48, right: 48, bottom: 48, left: 48 }; + +/** The live render state of one native map view; another view starts from none. */ +interface RenderStateOfView { + viewKey: string; + state: NativeMarkerViewRenderState; +} + +/** + * The SDK markers and the live ones, which the native pipeline clusters and + * filters as one list. A live marker whose id a bulk `markers` entry already + * has is left out: the collector keeps the ids of children unique, not those + * of the bulk prop. + */ +function withLiveMarkers( + markers: MarkerDescriptor[], + markerViews: CollectedMarkerView[], + isBulk: boolean, +): MarkerDescriptor[] { + if (markerViews.length === 0) { + return markers; + } + const bulkIds = isBulk ? new Set(markers.map((marker) => marker.id)) : null; + const combined = markers.slice(); + for (const markerView of markerViews) { + if (bulkIds?.has(markerView.id) === true) { + warnOverlay( + `MarkerView "${markerView.id}" skipped: a marker in the markers prop has the same id.`, + ); + continue; + } + combined.push(markerView.descriptor); + } + return combined; +} export function MapView({ ref, @@ -61,6 +114,7 @@ export function MapView({ applePoiDetailPresentation, customMapStyle, clusteringEnabled, + renderCluster, mapPadding, markerEnteringAnimation, clusterEnteringAnimation, @@ -88,6 +142,7 @@ export function MapView({ const commands = useMapViewCommands(nativeViewKey); const { markers: collectedMarkers, + markerViews: collectedMarkerViews, polylines: collectedPolylines, polygons: collectedPolygons, circles: collectedCircles, @@ -126,7 +181,11 @@ export function MapView({ // re-serialize each one across JSI, so hand back the previous value when // nothing actually changed. const markers = useStableValue( - normalizedBulkMarkers ?? collectedMarkers, + withLiveMarkers( + normalizedBulkMarkers ?? collectedMarkers, + collectedMarkerViews, + normalizedBulkMarkers != null, + ), markerListsEqual, ); const polylines = useStableValue( @@ -165,6 +224,107 @@ export function MapView({ | ((event: PoiPressEvent) => void) | undefined; + // Which live markers and JSX clusters native shows. It changes as markers + // cluster, uncluster or leave the viewport, never per camera frame: the + // native side moves mounted markers on its own. + const [renderStateOfView, setRenderStateOfView] = + useState(null); + const renderState = + renderStateOfView?.viewKey === nativeViewKey + ? renderStateOfView.state + : null; + const usesMarkerViews = + collectedMarkerViews.length > 0 || + (renderCluster != null && clusteringEnabled === true); + const handleMarkerViewRenderState = useCallback( + (state: NativeMarkerViewRenderState) => { + setRenderStateOfView((previous) => + previous?.viewKey === nativeViewKey && + markerViewRenderStatesEqual(previous.state, state) + ? previous + : { viewKey: nativeViewKey, state }, + ); + }, + [nativeViewKey], + ); + + const pressCluster = useCallback( + (cluster: NativeMarkerViewCluster) => { + onClusterPress?.(cluster.markerIds, cluster.coordinate); + const coordinates = clusterMemberCoordinates(cluster.markerIds, markers); + if (coordinates.length === 0) { + return Promise.resolve(); + } + return commands.run((hybrid) => + hybrid.fitToCoordinates(coordinates, CLUSTER_FIT_PADDING, true), + ); + }, + [commands, markers, onClusterPress], + ); + + const mountedMarkerViews = useMemo(() => { + if (renderState == null || renderState.markerIds.length === 0) { + return null; + } + const shown = new Set(renderState.markerIds); + const elements: ReactElement[] = []; + // In children order, which is also the order they are drawn in. + for (const markerView of collectedMarkerViews) { + if (!shown.has(markerView.id)) { + continue; + } + // Keyed by marker id, so React keeps each subtree as others come and go. + elements.push( + markerView.element.key === markerView.id + ? markerView.element + : cloneElement(markerView.element, { key: markerView.id }), + ); + } + return elements; + }, [collectedMarkerViews, renderState]); + + const clusterViews = useMemo(() => { + if ( + renderCluster == null || + clusteringEnabled !== true || + renderState == null || + renderState.clusters.length === 0 + ) { + return null; + } + const elements: ReactElement[] = []; + for (const cluster of renderState.clusters) { + const element = renderCluster({ + id: cluster.id, + coordinate: cluster.coordinate, + count: cluster.markerIds.length, + markerIds: cluster.markerIds, + onPress: () => pressCluster(cluster), + }); + if (element == null) { + continue; + } + if (!isMarkerViewElement(element)) { + warnOverlay('renderCluster must return a MarkerView or null.'); + continue; + } + const props = { ...element.props, coordinate: cluster.coordinate }; + const problem = markerViewPropsProblem(props); + if (problem != null) { + warnOverlay(`cluster "${cluster.id}" skipped: ${problem}`); + continue; + } + // Kept under one key while its count and members change. + elements.push( + cloneElement(element, { + key: `cluster:${cluster.id}`, + coordinate: cluster.coordinate, + }), + ); + } + return elements; + }, [clusteringEnabled, pressCluster, renderCluster, renderState]); + const handleHybridRef = useCallback( (nativeRef: NativeMapViewHybrid) => { commands.attach(nativeRef); @@ -254,6 +414,9 @@ export function MapView({ ); const onLongPressCallback = useNitroCallback(onLongPress); const onClusterPressCallback = useNitroCallback(onClusterPress); + const onMarkerViewRenderStateCallback = useNitroCallback( + usesMarkerViews ? handleMarkerViewRenderState : undefined, + ); const onMarkerPressCallback = useNitroCallback( hasMarkerPress ? handleMarkerPress : undefined, ); @@ -331,6 +494,8 @@ export function MapView({ applePoiDetailPresentation={applePoiDetailPresentation} customMapStyle={customMapStyle} clusteringEnabled={clusteringEnabled} + customClusterViews={renderCluster != null} + onMarkerViewRenderState={onMarkerViewRenderStateCallback} mapPadding={cameraProps.mapPadding} markerEnteringAnimation={markerEntering} clusterEnteringAnimation={clusterEntering} @@ -350,6 +515,13 @@ export function MapView({ onPolylinePress={onPolylinePressCallback} onPolygonPress={onPolygonPressCallback} onCirclePress={onCirclePressCallback} - /> + > + {mountedMarkerViews != null || clusterViews != null ? ( + + {mountedMarkerViews} + {clusterViews} + + ) : null} + ); } diff --git a/package/src/components/MarkerView.tsx b/package/src/components/MarkerView.tsx new file mode 100644 index 00000000..e77e36bf --- /dev/null +++ b/package/src/components/MarkerView.tsx @@ -0,0 +1,52 @@ +import { useContext, useMemo } from 'react'; +import { NativeMarkerView } from '../native/MarkerViewNative'; +import type { OverlayComponentType } from '../overlays/overlayType'; +import { OverlayType } from '../overlays/overlayType'; +import { warnOverlay } from '../overlays/warnOverlay'; +import type { MarkerViewProps } from '../types/markerView'; +import { MarkerViewHostContext } from './MarkerViewHostContext'; + +/** + * A marker drawn by live React Native content instead of an image: state, + * `Pressable`, Reanimated animations and all. + * + * Use it as a direct child of {@linkcode MapView}. The map places it with its + * native projection on every camera frame - no JS runs and nothing is + * snapshotted while the camera moves - and mounts its JSX only while the + * native clustering and viewport pipeline shows it. A marker grouped into a + * cluster or scrolled far off screen is unmounted, so keep state that has to + * outlive that in your app, not in the marker. + * + * For large static datasets prefer {@linkcode Marker}: an SDK-drawn image + * costs far less than a live view. + */ +export function MarkerView({ + coordinate, + width, + height, + anchor, + children, +}: MarkerViewProps) { + const isInMap = useContext(MarkerViewHostContext); + const style = useMemo( + () => ({ position: 'absolute' as const, left: 0, top: 0, width, height }), + [width, height], + ); + + if (!isInMap) { + warnOverlay('MarkerView renders only as a direct child of MapView.'); + return null; + } + + return ( + + {children} + + ); +} + +(MarkerView as OverlayComponentType).overlayType = OverlayType.MarkerView; diff --git a/package/src/components/MarkerViewHostContext.ts b/package/src/components/MarkerViewHostContext.ts new file mode 100644 index 00000000..84ada2a3 --- /dev/null +++ b/package/src/components/MarkerViewHostContext.ts @@ -0,0 +1,4 @@ +import { createContext } from 'react'; + +/** True inside the native map view that `MapView` mounts live markers into. */ +export const MarkerViewHostContext = createContext(false); diff --git a/package/src/components/index.ts b/package/src/components/index.ts index 0f2bc078..d7f28389 100644 --- a/package/src/components/index.ts +++ b/package/src/components/index.ts @@ -1,5 +1,6 @@ export { MapView } from './MapView'; export { Marker } from './Marker'; +export { MarkerView } from './MarkerView'; export { Polyline } from './Polyline'; export { Polygon } from './Polygon'; export { Circle } from './Circle'; diff --git a/package/src/hooks/useCollectedOverlays.ts b/package/src/hooks/useCollectedOverlays.ts index c5fc89ad..b97c0d4b 100644 --- a/package/src/hooks/useCollectedOverlays.ts +++ b/package/src/hooks/useCollectedOverlays.ts @@ -7,6 +7,7 @@ import type { PolylineDescriptor, } from '../native/specs/overlays'; import { collectOverlayChildren } from '../overlays/collectOverlayChild'; +import type { CollectedMarkerView } from '../overlays/overlayCollect'; import { createPositionalIdTracker, trackPositionalIds, @@ -16,6 +17,7 @@ import type { OverlayCallbacks } from '../overlays/overlayCollect'; export interface CollectedOverlays { markers: MarkerDescriptor[]; + markerViews: CollectedMarkerView[]; polylines: PolylineDescriptor[]; polygons: PolygonDescriptor[]; circles: CircleDescriptor[]; @@ -41,6 +43,7 @@ export function useCollectedOverlays(children: ReactNode): CollectedOverlays { return { markers: state.markers, + markerViews: state.markerViews, polylines: state.polylines, polygons: state.polygons, circles: state.circles, diff --git a/package/src/index.ts b/package/src/index.ts index b274c44c..41a5ba82 100644 --- a/package/src/index.ts +++ b/package/src/index.ts @@ -1,6 +1,7 @@ export { MapView, Marker, + MarkerView, Polyline, Polygon, Circle, @@ -31,6 +32,9 @@ export type { MarkerDescriptor, MarkerPoint, MarkerProps, + MarkerViewCluster, + MarkerViewProps, + RenderMarkerViewCluster, OverlayEnteringAnimation, OverlayEnteringAnimationConfig, OverlayEnteringAnimationPreset, diff --git a/package/src/native/MarkerViewNative.ts b/package/src/native/MarkerViewNative.ts new file mode 100644 index 00000000..7ea0ffe0 --- /dev/null +++ b/package/src/native/MarkerViewNative.ts @@ -0,0 +1,14 @@ +import { getHostComponent } from 'react-native-nitro-modules'; +import type { HybridViewMethods } from 'react-native-nitro-modules'; +// The view config is generated by Nitrogen and required by Fabric. +import MarkerViewConfig from '../../nitrogen/generated/shared/json/MarkerViewConfig.json'; +import type { MarkerViewProps } from './specs/MarkerView.nitro'; + +/** + * The raw native host of a live marker. Consumers use `MarkerView`, which + * `MapView` mounts only while the native pipeline displays the marker. + */ +export const NativeMarkerView = getHostComponent< + MarkerViewProps, + HybridViewMethods +>('MarkerView', () => MarkerViewConfig); diff --git a/package/src/native/specs/MapView.nitro.ts b/package/src/native/specs/MapView.nitro.ts index b7741543..2d39cb00 100644 --- a/package/src/native/specs/MapView.nitro.ts +++ b/package/src/native/specs/MapView.nitro.ts @@ -116,6 +116,22 @@ export interface NativePoiPressEvent { placeId?: string; } +/** A cluster that `renderCluster` draws as a live `MarkerView`. */ +export interface NativeMarkerViewCluster { + id: string; + coordinate: Coordinate; + markerIds: string[]; +} + +/** + * Which live `MarkerView` hosts the native clustering and viewport pipeline + * currently shows. Sent only when that set changes, never per camera frame. + */ +export interface NativeMarkerViewRenderState { + markerIds: string[]; + clusters: NativeMarkerViewCluster[]; +} + /** * Native props for the {@linkcode MapView} Nitro HybridView. * @@ -177,6 +193,12 @@ export interface MapViewProps extends HybridViewProps { /** Whether to cluster nearby markers. */ clusteringEnabled?: boolean; + /** Whether clusters are drawn by `renderCluster` instead of the SDK badge. */ + customClusterViews?: boolean; + + /** Receives the displayed live marker set whenever it changes. */ + onMarkerViewRenderState?: (state: NativeMarkerViewRenderState) => void; + /** Padding applied to map edges, in density-independent pixels. */ mapPadding?: EdgePadding; diff --git a/package/src/native/specs/MarkerView.nitro.ts b/package/src/native/specs/MarkerView.nitro.ts new file mode 100644 index 00000000..3f867ad4 --- /dev/null +++ b/package/src/native/specs/MarkerView.nitro.ts @@ -0,0 +1,13 @@ +import type { HybridView, HybridViewProps } from 'react-native-nitro-modules'; +import type { Coordinate } from '../../types/coordinate'; +import type { MarkerAnchor } from './overlays'; + +/** Native props for the live `MarkerView` host. */ +export interface MarkerViewProps extends HybridViewProps { + coordinate: Coordinate; + /** Point of the host attached to `coordinate`; defaults to bottom-center. */ + anchor?: MarkerAnchor; +} + +/** A live Fabric subtree that the map positions with its native projection. */ +export type MarkerView = HybridView; diff --git a/package/src/native/specs/overlays.ts b/package/src/native/specs/overlays.ts index 93015e01..3ad3e512 100644 --- a/package/src/native/specs/overlays.ts +++ b/package/src/native/specs/overlays.ts @@ -78,6 +78,12 @@ export interface MarkerDescriptor { /** Whether the marker participates in clustering when enabled on the map. */ clusterable?: boolean; + /** + * Drawn by a live `MarkerView` host instead of the SDK. Native clustering and + * viewport filtering still place it; the SDK draws nothing for it. + */ + liveView?: boolean; + /** Custom marker image. */ image?: MarkerImage; diff --git a/package/src/overlays/__tests__/collectMarkerView.test.ts b/package/src/overlays/__tests__/collectMarkerView.test.ts new file mode 100644 index 00000000..916cb977 --- /dev/null +++ b/package/src/overlays/__tests__/collectMarkerView.test.ts @@ -0,0 +1,146 @@ +import { createElement, Fragment, type ReactNode } from 'react'; +import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test'; +import { Marker } from '../../components/Marker'; +import type { MarkerViewProps } from '../../types/markerView'; +import { collectOverlayChildren } from '../collectOverlayChild'; +import { + isMarkerViewElement, + markerViewPropsProblem, +} from '../collectMarkerView'; +import { OverlayType, type OverlayComponentType } from '../overlayType'; + +// Stands in for MarkerView, whose native host cannot load under bun. It is +// matched the same way: by `overlayType`, as a copy from another module path is. +function LiveMarker(_props: MarkerViewProps) { + return null; +} +(LiveMarker as OverlayComponentType).overlayType = OverlayType.MarkerView; + +const warnSpy = spyOn(console, 'warn'); +const previousDev = (globalThis as { __DEV__?: boolean }).__DEV__; + +beforeEach(() => { + warnSpy.mockClear(); + (globalThis as { __DEV__?: boolean }).__DEV__ = true; +}); + +afterEach(() => { + (globalThis as { __DEV__?: boolean }).__DEV__ = previousDev; +}); + +const WARSAW = { latitude: 52.2297, longitude: 21.0122 }; + +function live( + key: string | null, + props: Partial = {}, +): ReactNode { + return createElement(LiveMarker, { + key: key ?? undefined, + coordinate: WARSAW, + width: 96, + height: 48, + ...props, + }); +} + +function collect(children: ReactNode) { + return collectOverlayChildren(children, { + resolveMarkerImage: () => undefined, + }); +} + +describe('collecting MarkerView children', () => { + test('a live marker is placed by the native pipeline but drawn by no SDK marker', () => { + const state = collect([live('price', { clusterable: false })]); + + expect(state.markers).toEqual([]); + expect(state.markerViews).toHaveLength(1); + expect(state.markerViews[0].id).toBe('price'); + expect(state.markerViews[0].descriptor).toEqual({ + id: 'price', + coordinate: WARSAW, + clusterable: false, + liveView: true, + }); + }); + + test('keeps its element, so MapView mounts the JSX the app rendered', () => { + const element = live('price'); + const state = collect([element]); + + expect(state.markerViews[0].element).toBe(element as never); + }); + + test('shares one set of ids with Marker, as both feed one native list', () => { + const state = collect([ + createElement(Marker, { key: 'a', coordinate: WARSAW }), + live('a'), + ]); + + expect(state.markers.map((marker) => marker.id)).toEqual(['a']); + expect(state.markerViews.map((view) => view.id)).toEqual(['a#2']); + }); + + test('an id prop wins over the key', () => { + const state = collect([live('row-1', { id: 'place-42' })]); + + expect(state.markerViews[0].id).toBe('place-42'); + }); + + test('skips what cannot be drawn with a warning instead of crashing the map', () => { + const state = collect([ + live('nan', { coordinate: { latitude: Number.NaN, longitude: 0 } }), + live('zero', { width: 0 }), + live('anchor', { anchor: { x: Number.POSITIVE_INFINITY, y: 1 } }), + live('ok'), + ]); + + expect(state.markerViews.map((view) => view.id)).toEqual(['ok']); + const messages = warnSpy.mock.calls.map((call) => String(call[0])); + expect( + messages.some((message) => + message.includes('"nan" skipped: invalid coordinate'), + ), + ).toBe(true); + expect( + messages.some((message) => + message.includes('"zero" skipped: width and height'), + ), + ).toBe(true); + expect( + messages.some((message) => message.includes('"anchor" skipped: anchor')), + ).toBe(true); + }); + + test('like every other overlay, only direct children are collected', () => { + const state = collect([ + createElement(Fragment, { key: 'group' }, live('nested')), + ]); + + expect(state.markerViews).toEqual([]); + }); +}); + +describe('isMarkerViewElement', () => { + test('recognizes a MarkerView by its overlay type', () => { + expect(isMarkerViewElement(live('a'))).toBe(true); + expect( + isMarkerViewElement(createElement(Marker, { coordinate: WARSAW })), + ).toBe(false); + expect(isMarkerViewElement(null)).toBe(false); + expect(isMarkerViewElement('text')).toBe(false); + }); +}); + +describe('markerViewPropsProblem', () => { + test('accepts anchors outside 0...1, which offset the marker on purpose', () => { + expect( + markerViewPropsProblem({ + coordinate: WARSAW, + width: 10, + height: 10, + anchor: { x: -0.5, y: 2 }, + }), + ).toBeNull(); + }); +}); diff --git a/package/src/overlays/__tests__/markerViewRenderState.test.ts b/package/src/overlays/__tests__/markerViewRenderState.test.ts new file mode 100644 index 00000000..700ed5a6 --- /dev/null +++ b/package/src/overlays/__tests__/markerViewRenderState.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from 'bun:test'; +import type { NativeMarkerViewRenderState } from '../../native/specs/MapView.nitro'; +import { + clusterMemberCoordinates, + markerViewRenderStatesEqual, +} from '../markerViewRenderState'; + +const state: NativeMarkerViewRenderState = { + markerIds: ['a', 'b'], + clusters: [ + { + id: 'c:grid', + coordinate: { latitude: 52, longitude: 21 }, + markerIds: ['c', 'd'], + }, + ], +}; + +describe('markerViewRenderStatesEqual', () => { + test('an equivalent state republished by native renders nothing', () => { + expect(markerViewRenderStatesEqual(state, structuredClone(state))).toBe( + true, + ); + expect(markerViewRenderStatesEqual(null, null)).toBe(true); + expect(markerViewRenderStatesEqual(null, state)).toBe(false); + }); + + test('a marker mounted or unmounted is a change', () => { + expect( + markerViewRenderStatesEqual(state, { ...state, markerIds: ['a'] }), + ).toBe(false); + }); + + test('a cluster that moved or changed members is a change, also at the same count', () => { + const moved = structuredClone(state); + moved.clusters[0].coordinate.longitude = 21.5; + expect(markerViewRenderStatesEqual(state, moved)).toBe(false); + + const swapped = structuredClone(state); + swapped.clusters[0].markerIds = ['c', 'e']; + expect(markerViewRenderStatesEqual(state, swapped)).toBe(false); + }); +}); + +describe('clusterMemberCoordinates', () => { + test('fits only the members, live and SDK markers alike', () => { + const first = { latitude: 52.2297, longitude: 21.0122 }; + const second = { latitude: 52.2302, longitude: 21.01285 }; + + expect( + clusterMemberCoordinates( + ['live', 'sdk'], + [ + { id: 'outside', coordinate: { latitude: 50, longitude: 20 } }, + { id: 'live', coordinate: first }, + { id: 'sdk', coordinate: second }, + ], + ), + ).toEqual([first, second]); + }); + + test('a stale cluster whose members are gone fits nothing', () => { + expect( + clusterMemberCoordinates( + ['removed'], + [{ id: 'new', coordinate: { latitude: 52, longitude: 21 } }], + ), + ).toEqual([]); + }); +}); diff --git a/package/src/overlays/__tests__/nullOptionalFields.test.ts b/package/src/overlays/__tests__/nullOptionalFields.test.ts index 43da2a6a..e65d23ae 100644 --- a/package/src/overlays/__tests__/nullOptionalFields.test.ts +++ b/package/src/overlays/__tests__/nullOptionalFields.test.ts @@ -190,7 +190,8 @@ const markerFields = { opacity: 0.9, enteringAnimation: { ...animation, ...animationFields }, } satisfies EveryOptionalField & - Record, unknown>; + // `liveView` is set by the MarkerView collector, never by app input. + Record, 'liveView'>, unknown>; const markerPropsFields = { ...markerFields, diff --git a/package/src/overlays/collectMarkerView.ts b/package/src/overlays/collectMarkerView.ts new file mode 100644 index 00000000..55a5eadb --- /dev/null +++ b/package/src/overlays/collectMarkerView.ts @@ -0,0 +1,62 @@ +import type { ReactElement } from 'react'; +import type { MarkerViewProps } from '../types/markerView'; +import { isValidCoordinate, isValidPoint } from '../utils/validateGeometry'; +import type { OverlayCollectorState } from './overlayCollect'; +import { OverlayType, type OverlayComponentType } from './overlayType'; +import { warnOverlay } from './warnOverlay'; + +export function isMarkerViewElement( + element: unknown, +): element is ReactElement { + const type = (element as ReactElement | null)?.type as + OverlayComponentType | undefined; + return type?.overlayType === OverlayType.MarkerView; +} + +function isValidSize(value: number | undefined): boolean { + return value != null && Number.isFinite(value) && value > 0; +} + +/** Why `props` cannot be drawn, or null when it can. */ +export function markerViewPropsProblem(props: MarkerViewProps): string | null { + if (!isValidCoordinate(props.coordinate)) { + return 'invalid coordinate'; + } + if (!isValidSize(props.width) || !isValidSize(props.height)) { + return 'width and height must be finite and positive'; + } + if (props.anchor != null && !isValidPoint(props.anchor)) { + return 'anchor must be finite'; + } + return null; +} + +/** + * Collects one `MarkerView` child. Native clustering and viewport filtering + * place it like any marker, but the SDK draws nothing for it: `MapView` mounts + * its JSX while the pipeline reports it on screen. + */ +export function collectMarkerView( + child: ReactElement, + id: string, + state: OverlayCollectorState, +): void { + const element = child as ReactElement; + const props = element.props; + const problem = markerViewPropsProblem(props); + if (problem != null) { + warnOverlay(`MarkerView "${id}" skipped: ${problem}`); + return; + } + + state.markerViews.push({ + id, + element, + descriptor: { + id, + coordinate: props.coordinate, + clusterable: props.clusterable ?? undefined, + liveView: true, + }, + }); +} diff --git a/package/src/overlays/collectOverlayChild.ts b/package/src/overlays/collectOverlayChild.ts index 209d70a4..a3ae134e 100644 --- a/package/src/overlays/collectOverlayChild.ts +++ b/package/src/overlays/collectOverlayChild.ts @@ -35,6 +35,7 @@ import { collectMarkerOverlay, type MarkerImageResolver, } from './collectMarkerOverlay'; +import { collectMarkerView } from './collectMarkerView'; function isOverlayChild( child: ReactElement, @@ -48,7 +49,7 @@ function isOverlayChild( } } - return child.type === component; + return component != null && child.type === component; } export interface OverlayCollectorDependencies { @@ -81,6 +82,17 @@ const overlayCollectors: OverlayCollector[] = [ collectMarkerOverlay(id, props, state, dependencies.resolveMarkerImage); }, }, + { + // Matched by `overlayType` alone: importing the component would pull its + // native host into everything that collects overlays. + overlayType: OverlayType.MarkerView, + component: undefined, + // One namespace with `Marker`: both feed one native marker list. + idKind: 'marker', + collect: (child, id, state) => { + collectMarkerView(child, id, state); + }, + }, { overlayType: OverlayType.Polyline, component: Polyline, diff --git a/package/src/overlays/descriptorEquality.ts b/package/src/overlays/descriptorEquality.ts index a31ccf1d..19047eb8 100644 --- a/package/src/overlays/descriptorEquality.ts +++ b/package/src/overlays/descriptorEquality.ts @@ -138,6 +138,7 @@ export function markerDescriptorsEqual( left.subtitle === right.subtitle && left.draggable === right.draggable && left.clusterable === right.clusterable && + left.liveView === right.liveView && markerImagesEqual(left.image, right.image) && left.markerColor === right.markerColor && pointsEqual(left.anchor, right.anchor) && diff --git a/package/src/overlays/markerViewRenderState.ts b/package/src/overlays/markerViewRenderState.ts new file mode 100644 index 00000000..5f903634 --- /dev/null +++ b/package/src/overlays/markerViewRenderState.ts @@ -0,0 +1,77 @@ +import type { + NativeMarkerViewCluster, + NativeMarkerViewRenderState, +} from '../native/specs/MapView.nitro'; +import type { Coordinate } from '../types/coordinate'; + +function stringListsEqual(left: string[], right: string[]): boolean { + if (left.length !== right.length) { + return false; + } + for (let index = 0; index < left.length; index += 1) { + if (left[index] !== right[index]) { + return false; + } + } + return true; +} + +function clustersEqual( + left: NativeMarkerViewCluster, + right: NativeMarkerViewCluster, +): boolean { + return ( + left.id === right.id && + left.coordinate.latitude === right.coordinate.latitude && + left.coordinate.longitude === right.coordinate.longitude && + stringListsEqual(left.markerIds, right.markerIds) + ); +} + +/** + * Whether two native render states mount the same JSX. Native sorts both lists, + * so equal states compare element by element. Lets a reinstalled callback that + * republishes the same state skip a render. + */ +export function markerViewRenderStatesEqual( + left: NativeMarkerViewRenderState | null, + right: NativeMarkerViewRenderState | null, +): boolean { + if (left === right) { + return true; + } + if (left == null || right == null) { + return false; + } + if ( + !stringListsEqual(left.markerIds, right.markerIds) || + left.clusters.length !== right.clusters.length + ) { + return false; + } + for (let index = 0; index < left.clusters.length; index += 1) { + if (!clustersEqual(left.clusters[index], right.clusters[index])) { + return false; + } + } + return true; +} + +/** + * Where a cluster's members are now. Fitting these, rather than the cluster + * region, always zooms far enough to split a dense cluster - the region has a + * minimum span. Members that have since disappeared are left out. + */ +export function clusterMemberCoordinates( + markerIds: readonly string[], + markers: readonly { id: string; coordinate: Coordinate }[], +): Coordinate[] { + const members = new Set(markerIds); + const coordinates: Coordinate[] = []; + for (const marker of markers) { + if (members.has(marker.id)) { + coordinates.push(marker.coordinate); + } + } + return coordinates; +} diff --git a/package/src/overlays/overlayCollect.ts b/package/src/overlays/overlayCollect.ts index 5b0ebafd..dc77bdad 100644 --- a/package/src/overlays/overlayCollect.ts +++ b/package/src/overlays/overlayCollect.ts @@ -4,6 +4,8 @@ import type { PolygonDescriptor, PolylineDescriptor, } from '../native/specs/overlays'; +import type { ReactElement } from 'react'; +import type { MarkerViewProps } from '../types/markerView'; import type { MarkerProps } from '../types/overlays'; import { createOverlayIdState, type OverlayIdState } from './overlayIds'; @@ -12,9 +14,17 @@ export interface OverlayCallbacks { onDragEnd?: (coordinate: MarkerProps['coordinate']) => void; } +/** A `MarkerView` child: its JSX, mounted while the map shows it, and what the pipeline places. */ +export interface CollectedMarkerView { + id: string; + element: ReactElement; + descriptor: MarkerDescriptor; +} + export interface OverlayCollectorState { registry: Map; markers: MarkerDescriptor[]; + markerViews: CollectedMarkerView[]; polylines: PolylineDescriptor[]; polygons: PolygonDescriptor[]; circles: CircleDescriptor[]; @@ -30,6 +40,7 @@ export function createOverlayCollectorState(): OverlayCollectorState { return { registry: new Map(), markers: [], + markerViews: [], polylines: [], polygons: [], circles: [], diff --git a/package/src/overlays/overlayType.ts b/package/src/overlays/overlayType.ts index 62260e9d..d2ffffc4 100644 --- a/package/src/overlays/overlayType.ts +++ b/package/src/overlays/overlayType.ts @@ -6,6 +6,7 @@ */ export const OverlayType = { Marker: 'NitroMaps.Marker', + MarkerView: 'NitroMaps.MarkerView', Polyline: 'NitroMaps.Polyline', Polygon: 'NitroMaps.Polygon', Circle: 'NitroMaps.Circle', diff --git a/package/src/types/index.ts b/package/src/types/index.ts index bdc28af4..620e2201 100644 --- a/package/src/types/index.ts +++ b/package/src/types/index.ts @@ -45,3 +45,8 @@ export type { GeojsonToOverlayOptions, } from './geojson'; export type { MapViewRef } from './ref'; +export type { + MarkerViewCluster, + MarkerViewProps, + RenderMarkerViewCluster, +} from './markerView'; diff --git a/package/src/types/map.ts b/package/src/types/map.ts index 7214f9e4..63416aaa 100644 --- a/package/src/types/map.ts +++ b/package/src/types/map.ts @@ -13,6 +13,7 @@ import type { } from '../native/specs/MapView.nitro'; import type { MarkerDescriptor, OverlayEnteringAnimation } from './overlays'; import type { EdgePadding, Region, RegionChangeDetails } from './region'; +import type { RenderMarkerViewCluster } from './markerView'; /** * Available map display styles. @@ -186,6 +187,12 @@ interface ExistingDefaultProviderProps extends BaseMapViewProps { /** Whether to cluster nearby markers. */ clusteringEnabled?: boolean; + /** + * Draws each cluster as a live `MarkerView` instead of the default badge. + * Needs `clusteringEnabled`. + */ + renderCluster?: RenderMarkerViewCluster; + /** Entering animation for marker clusters. */ clusterEnteringAnimation?: OverlayEnteringAnimation; } @@ -211,6 +218,12 @@ interface AppleMapViewProps extends BaseMapViewProps { /** Whether to cluster nearby markers. */ clusteringEnabled?: boolean; + /** + * Draws each cluster as a live `MarkerView` instead of the default badge. + * Needs `clusteringEnabled`. + */ + renderCluster?: RenderMarkerViewCluster; + /** Entering animation for marker clusters. */ clusterEnteringAnimation?: OverlayEnteringAnimation; } @@ -233,6 +246,12 @@ interface GoogleMapViewProps extends BaseMapViewProps { /** Whether to cluster nearby markers. */ clusteringEnabled?: boolean; + /** + * Draws each cluster as a live `MarkerView` instead of the default badge. + * Needs `clusteringEnabled`. + */ + renderCluster?: RenderMarkerViewCluster; + /** Entering animation for marker clusters. */ clusterEnteringAnimation?: OverlayEnteringAnimation; } @@ -244,6 +263,7 @@ interface OpenStreetMapViewProps extends BaseMapViewProps { applePoiDetailPresentation?: never; customMapStyle?: never; clusteringEnabled?: never; + renderCluster?: never; clusterEnteringAnimation?: never; onPoiPress?: never; } @@ -255,6 +275,7 @@ interface MapboxMapViewProps extends BaseMapViewProps { applePoiDetailPresentation?: never; customMapStyle?: never; clusteringEnabled?: never; + renderCluster?: never; clusterEnteringAnimation?: never; onPoiPress?: never; } diff --git a/package/src/types/markerView.ts b/package/src/types/markerView.ts new file mode 100644 index 00000000..a4520d87 --- /dev/null +++ b/package/src/types/markerView.ts @@ -0,0 +1,51 @@ +import type { ReactElement, ReactNode } from 'react'; +import type { Coordinate } from './coordinate'; +import type { MarkerAnchor } from './overlays'; + +/** Props of {@linkcode MarkerView}. */ +export interface MarkerViewProps { + /** + * Identifies the marker in cluster membership and `onClusterPress`. Shares + * one set of ids with `Marker`; defaults to the React key, else the position. + */ + id?: string; + + /** Geographic position of the anchor point. */ + coordinate: Coordinate; + + /** Whether the marker joins clusters when clustering is enabled on the map. Default true. */ + clusterable?: boolean; + + /** + * Fixed size of the marker in points / dp. It bounds layout, clipping and + * touches and is what `anchor` refers to: animate content inside it, not it. + */ + width: number; + height: number; + + /** Point of the marker placed on `coordinate`, as fractions of its size. Default bottom-center. */ + anchor?: MarkerAnchor; + + /** Live React Native content: state, `Pressable`, Reanimated animations. */ + children?: ReactNode; +} + +/** A cluster handed to `renderCluster`. */ +export interface MarkerViewCluster { + /** Stable while the cluster stays on screen, also as its count changes. */ + id: string; + coordinate: Coordinate; + count: number; + /** Ids of the markers it groups, `Marker` and `MarkerView` alike. */ + markerIds: string[]; + /** + * Calls `onClusterPress` and zooms the map to the cluster's markers, as a + * press on a default cluster badge does. Resolves when the camera arrives. + */ + onPress: () => Promise; +} + +/** Draws a cluster as a live marker. Must return a `MarkerView`, whose `coordinate` the map sets. */ +export type RenderMarkerViewCluster = ( + cluster: MarkerViewCluster, +) => ReactElement | null;