Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,45 @@ function ControlledMap() {
}
```

#### When the ref is usable

The native map is created after React commits, so `mapRef.current` is populated
before there is anything native behind it. Calls made in that window are held
and replayed, in the order they were made, as soon as the native map exists:

```tsx
import { useEffect, useRef } from 'react';
import {
MapView,
type Coordinate,
type MapViewRef,
} from 'react-native-better-maps';

function FittedMap({ points }: { points: Coordinate[] }) {
const mapRef = useRef<MapViewRef>(null);

useEffect(() => {
// Runs before the native map exists, and still moves the camera.
mapRef.current
?.fitToCoordinates(points, undefined, true)
.catch((error: Error) => console.warn(error.message));
}, [points]);

return <MapView ref={mapRef} style={{ flex: 1 }} />;
}
```

So no call needs `setTimeout`, a retry, or an `onMapReady` handler to be safe.
`onMapReady` reports something later and different - that the map finished
loading its tiles - and is the right hook for showing your own UI on top of a
map that has actually drawn.

A call still waiting when the map view unmounts rejects, as does any call made
afterwards, so handle the rejection the way the example above does. Development
builds wrapped in React's `<StrictMode>` see this on every mount: React tears
the effect down and sets it up again, the first call is rejected by that
teardown, and the second one does the work.

## Map providers

`MapView` accepts an optional `provider` prop:
Expand Down Expand Up @@ -575,6 +614,7 @@ A coordinate that arrives as `NaN` or out of range is dropped instead of being f

- An invalid `region` is ignored, and the map keeps the region it already had.
- An invalid `camera` is ignored the same way, and a pitch past the range the SDKs draw is pulled back to it rather than rejected.
- `setCamera` and `animateCamera` reject an invalid camera instead of ignoring it, straight away and on both platforms - unlike a prop, they have a promise to report it on. A pitch past the drawable range is pulled back for them too.
- An overlay whose coordinates, ring length or radius cannot be drawn is skipped; its neighbours still render.
- Anything supplied through `region`, `camera`, the bulk `markers` prop, or a `<Marker>` / `<Polyline>` / `<Polygon>` / `<Circle>` child is reported through `console.warn` in development.

Expand Down Expand Up @@ -704,6 +744,7 @@ See [example/.env.example](example/.env.example) for the supported environment v
| Provider throws before rendering | Check the [supported platforms](#supported-platforms) table. `openstreetmap` and `mapbox` are reserved for future support but do not render yet. |
| Expo Go does not load native maps | Use a development build after `expo prebuild`; native Nitro modules are not available in Expo Go. |
| Marker animations affect gesture smoothness | For very large marker sets, prefer clustering, shorter durations, or disable marker/cluster entering animations. |
| `MapView is not mounted` from a ref call | The map view has unmounted. Calls made before the native map exists are held and replayed, so a freshly mounted map is not the cause. |

## Development

Expand Down
25 changes: 25 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,31 @@ Map and overlay callbacks are wired through Nitro listeners on the HybridView. C
| `mapPadding` | Edge insets in density-independent pixels. Applied via `layoutMargins` (iOS) or `setPadding` (Android). |
| `fitToCoordinates(coords, padding?, animated?)` | Imperative ref method; fits camera to a set of coordinates with optional padding. |

### Imperative ref readiness

`MapViewRef` hands out a working handle during the commit that mounts the view,
which is earlier than the native map can exist. Two buffers close that gap, and
neither uses a timer:

- **JS** — Nitro delivers the `hybridRef` view prop one JS -> UI -> JS round trip
after the mount transaction, so `MapViewCommands` (`package/src/native/mapViewCommands.ts`)
holds every call made before it arrives and replays them in call order. Calls
left waiting when the view unmounts are rejected, and later calls reject
without reaching native. `setCamera`/`animateCamera` check the camera before
it is queued, so an invalid one rejects at once instead of waiting here.
- **Android** — `MapView.getMapAsync` answers later still, so
`DeferredGoogleMap` holds camera work until the `GoogleMap` exists, and
`configureMap` drains it after replaying the `region`/`camera` props. Without
it the adapter would accept a camera call and quietly do nothing.
`fitToCoordinates` then waits once more, in `DeferredLayout`, for the map
view's first layout pass, because `newLatLngBounds` throws on a view without a
size. It rejects if the view is released before that pass comes. iOS has no
equivalent window: `MKMapView`/`GMSMapView` exist as soon as the adapter is
installed.

`onMapReady` is a separate, later signal - the map finished loading tiles - and
is not a precondition for using the ref.

### Platform gaps (Phase 8)

- **Provider availability** — `apple` and `google` are implemented on iOS, and `google` is implemented on Android. `openstreetmap` and `mapbox` are planned provider adapters.
Expand Down
103 changes: 91 additions & 12 deletions example/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
useRef,
useState,
type ReactNode,
type Ref,
type RefObject,
} from 'react';
import { StatusBar } from 'expo-status-bar';
import {
Expand Down Expand Up @@ -89,6 +89,18 @@ const ANIMATION_OPTIONS: AnimationOption[] = [
{ id: 'none', label: 'Off', value: false },
];

/**
* Identity of the native map view. `MapScene` is keyed on it so its mount
* effects run exactly when the map they drive is created.
*/
function mapSceneKey(
scenario: MapScenario,
animationOption: AnimationOption,
provider: SupportedExampleProvider,
): string {
return `${scenario.id}:${animationOption.id}:${provider}`;
}

function getSupportedMapProviders(): SupportedExampleProvider[] {
switch (Platform.OS) {
case 'ios':
Expand Down Expand Up @@ -514,14 +526,72 @@ const ScenarioDock = memo(function ScenarioDock({
);
});

type MountCameraFitOptions = {
scenario: MapScenario;
mapRef?: RefObject<MapViewRef | null>;
mapPadding?: EdgePadding;
onResult: (result: string) => void;
};

/**
* Fits the camera to the scenario markers from a mount effect - the earliest a
* consumer can reach the ref, and earlier than the native map can exist. The
* promise result is reported verbatim so a silent failure cannot hide.
*/
function useMountCameraFit({
scenario,
mapRef,
mapPadding,
onResult,
}: MountCameraFitOptions) {
useEffect(() => {
if (scenario.advanced?.fitToCoordinatesOnMount !== true) {
return;
}

const coordinates = (scenario.markers ?? []).map(
(marker) => marker.coordinate,
);
const handle = mapRef?.current;
if (handle == null) {
onResult('Mount fit · no handle');
return;
}

onResult('Mount fit · pending');
// A scene that is swapped out while the call is in flight must not report
// its own rejection over the incoming scene's status.
let isCurrentScene = true;
handle
.fitToCoordinates(coordinates, mapPadding, true)
.then(() => {
if (isCurrentScene) {
onResult('Mount fit · resolved');
}
})
.catch((error: Error) => {
if (isCurrentScene) {
onResult(`Mount fit · rejected: ${error.message}`);
}
});

return () => {
isCurrentScene = false;
};
// Mount only: this scene is keyed to the native map view it drives.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}

type MapSceneProps = {
ref?: Ref<MapViewRef>;
ref?: RefObject<MapViewRef | null>;
scenario: MapScenario;
provider: SupportedExampleProvider;
mapType: MapType;
mapPadding?: EdgePadding;
animationOption: AnimationOption;
onMapReady: () => void;
onMountFitResult: (result: string) => void;
onClusterPress: (markerIds: string[], coordinate: Coordinate) => void;
onMarkerPress: (id: string) => void;
onMarkerDragEnd: (id: string, coordinate: Coordinate) => void;
Expand All @@ -541,6 +611,7 @@ const MapScene = memo(function MapScene({
mapPadding,
animationOption,
onMapReady,
onMountFitResult,
onClusterPress,
onMarkerPress,
onMarkerDragEnd,
Expand All @@ -551,6 +622,13 @@ const MapScene = memo(function MapScene({
onRegionChange,
onRegionChangeComplete,
}: MapSceneProps) {
useMountCameraFit({
scenario,
mapRef: ref,
mapPadding,
onResult: onMountFitResult,
});

const commonMapProps = {
style: styles.map,
mapType,
Expand Down Expand Up @@ -584,7 +662,6 @@ const MapScene = memo(function MapScene({
return (
<MapView
ref={ref}
key={`${scenario.id}:${animationOption.id}:apple`}
{...commonMapProps}
provider="apple"
showsScale={scenario.advanced?.showsScale}
Expand All @@ -595,14 +672,7 @@ const MapScene = memo(function MapScene({
);
}

return (
<MapView
ref={ref}
key={`${scenario.id}:${animationOption.id}:google`}
{...commonMapProps}
provider="google"
/>
);
return <MapView ref={ref} {...commonMapProps} provider="google" />;
});

type StatusHeaderProps = {
Expand Down Expand Up @@ -841,9 +911,16 @@ export default function App() {
[],
);

const handleMountFitResult = useCallback((result: string) => {
setStatus(result);
}, []);

const handleMapReady = useCallback(() => {
setMapReady(true);
setStatus(scenario.name);
// The mount-effect result is the point of that scenario; do not bury it.
if (scenario.advanced?.fitToCoordinatesOnMount !== true) {
setStatus(scenario.name);
}

if (
scenario.advanced?.fitToCoordinatesOnReady &&
Expand Down Expand Up @@ -898,12 +975,14 @@ export default function App() {
<View style={styles.container}>
<MapScene
ref={mapRef}
key={mapSceneKey(scenario, animationOption, provider)}
scenario={scenario}
provider={provider}
mapType={MAP_TYPES[mapTypeIndex]}
mapPadding={mapPadding}
animationOption={animationOption}
onMapReady={handleMapReady}
onMountFitResult={handleMountFitResult}
onClusterPress={handleClusterPress}
onMarkerPress={handleMarkerPress}
onMarkerDragEnd={handleMarkerDragEnd}
Expand Down
2 changes: 2 additions & 0 deletions example/examples/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import { deliveryZoneScenario } from './deliveryZone';
import { geojsonScenario } from './geojson';
import { landmarksScenario } from './landmarks';
import { mountEffectCameraScenario } from './mountEffectCamera';
import { createScenarioOverlayProps } from './overlaySource';
import { riverRouteScenario } from './riverRoute';
import type { MapScenario } from './types';
Expand All @@ -39,4 +40,5 @@ export const MAP_SCENARIOS: MapScenario[] = [
geojsonScenario,
advancedFeaturesScenario,
applePoiDetailsScenario,
mountEffectCameraScenario,
];
47 changes: 47 additions & 0 deletions example/examples/mountEffectCamera.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { MapScenario } from './types';

/**
* Starts tight on one city while the markers span the whole country, so the
* mount-effect `fitToCoordinates` is unmistakable either way. The status header
* reports how its promise settled.
*/
export const mountEffectCameraScenario: MapScenario = {
id: 'mount-effect-camera',
name: 'Fit on mount',
description: 'fitToCoordinates from a mount effect, with the promise result',
region: {
latitude: 52.2297,
longitude: 21.0122,
latitudeDelta: 0.06,
longitudeDelta: 0.06,
},
markers: [
{
id: 'gdansk',
coordinate: { latitude: 54.352, longitude: 18.6466 },
title: 'Gdańsk',
subtitle: 'North',
},
{
id: 'szczecin',
coordinate: { latitude: 53.4285, longitude: 14.5528 },
title: 'Szczecin',
subtitle: 'West',
},
{
id: 'rzeszow',
coordinate: { latitude: 50.0413, longitude: 21.999 },
title: 'Rzeszów',
subtitle: 'South east',
},
{
id: 'wroclaw',
coordinate: { latitude: 51.1079, longitude: 17.0385 },
title: 'Wrocław',
subtitle: 'South west',
},
],
advanced: {
fitToCoordinatesOnMount: true,
},
};
1 change: 1 addition & 0 deletions example/examples/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface MapScenarioAdvancedOptions {
customMapStyle?: string;
mapPadding?: EdgePadding;
fitToCoordinatesOnReady?: boolean;
fitToCoordinatesOnMount?: boolean;
}

export interface MapScenario {
Expand Down
Loading
Loading