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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 56 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ Built with [Nitro Modules](https://nitro.margelo.com/) for high-performance nati
- **Provider-aware props** - TypeScript narrows provider-specific props with `MapViewPropsForProvider<P>`.
- **Markers and overlays** - Markers with title/subtitle callouts and drag support, plus polylines, polygons, circles, and GeoJSON FeatureCollections.
- **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.
- **Native entering animations** - Configurable marker and cluster entrance animations.
Expand Down Expand Up @@ -308,6 +309,33 @@ Provider-specific props narrow the callback payload:
| `google` | `{ provider: 'google', coordinate, name, placeId }` |
| omitted | `ApplePoiPressEvent \| GooglePoiPressEvent` because the runtime default depends on platform |

### Native POI details on Apple Maps

Apple MapKit can present its own place details for a selected point of interest through `MKSelectionAccessory.mapItemDetail(...)` on iOS 18+. Set `applePoiDetailPresentation` to opt in. The prop is accepted for `provider="apple"` and when the provider is omitted, and rejected for `google`, `openstreetmap`, and `mapbox`.

```tsx
<MapView
provider="apple"
style={{ flex: 1 }}
applePoiDetailPresentation="callout"
onPoiPress={(event) => {
console.log(event.name, event.category);
}}
/>
```

| Value | MapKit presentation |
| -------------- | ----------------------------------------------------- |
| `'automatic'` | MapKit picks the presentation for the current context |
| `'callout'` | Callout anchored to the selected place |
| `'sheet'` | Sheet from the map's view controller; falls back to callout if none is available |
| `'openInMaps'` | Affordance that opens the place in the Maps app |

- Setting the prop enables selectable points of interest on its own; `onPoiPress` is optional. When both are set, the event fires immediately and the native details open for the same tap.
- Without the prop, a POI tap emits `onPoiPress` and the native selection is cleared right away. With the prop, the place stays selected while its details are shown.
- On iOS 16 and 17 the prop is a no-op: POI taps still emit `onPoiPress` and the selection is cleared, but no native details appear.
- The Google Maps SDK (iOS and Android) has no equivalent native place-detail surface, so Google POI taps remain event-only.

## Custom marker images

Markers support custom bitmap icons with positioning and styling options:
Expand Down Expand Up @@ -557,6 +585,7 @@ setMarkers((current) =>
| Overlay press events | Supported | Supported | Supported |
| GeoJSON overlays | Supported (JS conversion) | Supported (JS conversion) | Supported (JS conversion) |
| Native POI press events | Supported on iOS 16+ | Supported | Supported |
| Native POI details | Supported on iOS 18+ (callout, sheet, Open in Maps) | Unsupported; taps stay event-only | Unsupported; taps stay event-only |
| Marker entering animation | System + `fade`, `fade-scale` | System + `fade`; scale fallback | System + `fade`; scale fallback |
| Cluster entering animation | System + `fade`, `fade-scale` | System + `fade`; scale fallback | System + `fade`; scale fallback |
| Clustering | Supported | Supported | Supported |
Expand All @@ -578,32 +607,33 @@ setMarkers((current) =>

### Types

| Type | Description |
| --------------------------- | ---------------------------------------------------- |
| `Coordinate` | `{ latitude, longitude }` |
| `Region` | Center + span |
| `Camera` | Position, zoom, heading, pitch |
| `MapType` | `'standard' \| 'satellite' \| 'hybrid' \| 'terrain'` |
| `MapProvider` | `'apple' \| 'google' \| 'openstreetmap' \| 'mapbox'` |
| `PoiPressEvent` | Provider-discriminated native POI press payload |
| `ApplePoiPressEvent` | Apple Maps POI payload with category |
| `GooglePoiPressEvent` | Google Maps POI payload with place ID |
| `ApplePoiCategory` | Known MapKit POI categories plus `unknown` |
| `MapViewRef` | Imperative handle for camera control |
| `MapViewProps` | Props for `MapView` |
| `MapViewPropsForProvider` | Provider-specific `MapView` props |
| `MarkerDescriptor` | Bulk marker descriptor |
| `MarkerProps` | Props for `Marker` |
| `MarkerImage` | Resolved marker image descriptor |
| `MarkerAnchor` | Anchor point on marker image (0..1) |
| `MarkerPoint` | Point offset in dp |
| `OverlayEnteringAnimation` | Marker / marker-cluster entering animation config |
| `PolylineProps` | Props for `Polyline` |
| `PolygonProps` | Props for `Polygon` |
| `CircleProps` | Props for `Circle` |
| `GeojsonProps` | Props for `Geojson` |
| `GeojsonFeature` | Feature passed to `Geojson` `onPress` |
| `GeojsonOverlayDescriptors` | Result of `geojsonToOverlayDescriptors` |
| Type | Description |
| ---------------------------- | ----------------------------------------------------- |
| `Coordinate` | `{ latitude, longitude }` |
| `Region` | Center + span |
| `Camera` | Position, zoom, heading, pitch |
| `MapType` | `'standard' \| 'satellite' \| 'hybrid' \| 'terrain'` |
| `MapProvider` | `'apple' \| 'google' \| 'openstreetmap' \| 'mapbox'` |
| `PoiPressEvent` | Provider-discriminated native POI press payload |
| `ApplePoiPressEvent` | Apple Maps POI payload with category |
| `GooglePoiPressEvent` | Google Maps POI payload with place ID |
| `ApplePoiCategory` | Known MapKit POI categories plus `unknown` |
| `ApplePoiDetailPresentation` | `'automatic' \| 'callout' \| 'sheet' \| 'openInMaps'` |
| `MapViewRef` | Imperative handle for camera control |
| `MapViewProps` | Props for `MapView` |
| `MapViewPropsForProvider` | Provider-specific `MapView` props |
| `MarkerDescriptor` | Bulk marker descriptor |
| `MarkerProps` | Props for `Marker` |
| `MarkerImage` | Resolved marker image descriptor |
| `MarkerAnchor` | Anchor point on marker image (0..1) |
| `MarkerPoint` | Point offset in dp |
| `OverlayEnteringAnimation` | Marker / marker-cluster entering animation config |
| `PolylineProps` | Props for `Polyline` |
| `PolygonProps` | Props for `Polygon` |
| `CircleProps` | Props for `Circle` |
| `GeojsonProps` | Props for `Geojson` |
| `GeojsonFeature` | Feature passed to `Geojson` `onPress` |
| `GeojsonOverlayDescriptors` | Result of `geojsonToOverlayDescriptors` |

### Utilities

Expand Down
56 changes: 56 additions & 0 deletions docs/adr/0005-apple-native-poi-detail-presentation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# ADR 0005: Native Apple Maps POI detail presentation

## Status

Accepted

## Context

`onPoiPress` (issue #33) reports taps on provider-owned points of interest as typed events on
Apple Maps and Google Maps. Some apps want the provider's own place-detail UI instead of
rebuilding cards, sheets, and callouts in React Native.

Apple MapKit has a real native surface for this on iOS 18+: `MKAnnotationView.selectionAccessory`
accepts `MKSelectionAccessory.mapItemDetail(...)`, which works for `MKMapFeatureAnnotation` and
can present the place as a callout, a sheet, or an "Open in Maps" affordance. The Google Maps
SDK for iOS and Android exposes POI taps only as events; it has no native place-detail surface.

The library targets iOS 16.0, so the MapKit API is available at compile time but must be gated
at runtime.

## Decision

- Add an **Apple-only** prop, `applePoiDetailPresentation`, with the values `'automatic'`,
`'callout'`, `'sheet'`, and `'openInMaps'`. The values map 1:1 onto MapKit's
`MapItemDetailPresentationStyle` (`'callout'` uses the automatic callout style). Omitting the
prop disables native details; there is no `'disabled'` string.
- The prop is typed on `provider="apple"` and on the omitted-provider props (iOS defaults to
Apple), and rejected with `never` on `google`, `openstreetmap`, and `mapbox`, following the
`googleMapId` / `showsScale` convention. Android and the iOS Google adapter store the value
and ignore it.
- The prop is **independent of `onPoiPress`**: either one enables
`MKMapView.selectableMapFeatures = .pointsOfInterest`. When both are set, the event is
emitted immediately and the native details open for the same tap. There is no separate flag
to decouple them.
- **Selection lifecycle**: with a presentation configured, the selected POI stays selected so
MapKit can show the callout or sheet. Without one (or on iOS < 18), the POI is deselected
right after `onPoiPress` is emitted, which is what #33 specified.
- **Degradation**: on iOS 16 and 17 the prop is a silent no-op. POI taps still emit
`onPoiPress`, and the selection is cleared. The limitation is documented in the README and
the provider feature matrix rather than warned about at runtime.
- The accessory is supplied through the iOS 18 `mapView(_:selectionAccessoryFor:)` delegate
hook, so MapKit keeps rendering its own POI annotation view; the library never replaces
the feature view or copies its icon style.

## Consequences

- Apps get MapKit's own place details with one prop and no React Native UI work.
- The API deliberately does not promise Google parity. If the Google Maps SDK ever exposes a
native place-detail surface, it should get its own provider-specific prop rather than a
shared one.
- The `'sheet'` style relies on MapKit presenting from the map view's nearest view controller.
In a React Native app that is the root view controller or the controller of a `Modal`. When no
presenter is available, `ApplePoiDetailPresentation.toMKSelectionAccessory(presentedFrom:)`
falls back from `.sheet` to `.callout` so place details still appear.
- React Native POI detail components, custom callout content, and cross-provider parity remain
out of scope.
87 changes: 68 additions & 19 deletions example/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import Animated, {
} from 'react-native-reanimated';
import {
MapView,
type ApplePoiDetailPresentation,
type Coordinate,
type EdgePadding,
type MapProvider,
Expand All @@ -49,11 +50,15 @@ import {
Region,
} from 'react-native-better-maps';
import {
APPLE_POI_DETAILS_DEFAULT_PRESENTATION,
APPLE_POI_DETAILS_SCENARIO_ID,
MAP_SCENARIOS,
type MapScenario,
createApplePoiDetailsScenario,
createCustomMarkerImagesScenario,
createScenarioOverlayProps,
CUSTOM_MARKER_IMAGES_SCENARIO_ID,
nextApplePoiDetailPresentation,
} from './examples';

const MAP_TYPES: MapType[] = ['standard', 'satellite', 'hybrid'];
Expand Down Expand Up @@ -294,6 +299,7 @@ type ScenarioDockProps = {
customMarkerFlat: boolean;
onCycleCustomMarkerRotation: () => void;
onToggleCustomMarkerFlat: () => void;
onCycleApplePoiDetailMode: () => void;
};

const ScenarioDock = memo(function ScenarioDock({
Expand All @@ -316,7 +322,10 @@ const ScenarioDock = memo(function ScenarioDock({
customMarkerFlat,
onCycleCustomMarkerRotation,
onToggleCustomMarkerFlat,
onCycleApplePoiDetailMode,
}: ScenarioDockProps) {
const applePoiDetailPresentation =
scenario.advanced?.applePoiDetailPresentation;
const chevronRotation = useSharedValue(0);

useEffect(() => {
Expand Down Expand Up @@ -455,6 +464,20 @@ const ScenarioDock = memo(function ScenarioDock({
</ScalePressable>
</View>
) : null}

{applePoiDetailPresentation != null ? (
<View style={styles.actionRow}>
<ScalePressable
onPress={onCycleApplePoiDetailMode}
style={[styles.actionButton, styles.actionButtonAccent]}
>
<Text style={styles.actionButtonIcon}>◉</Text>
<Text style={styles.actionButtonText}>
POI · {applePoiDetailPresentation}
</Text>
</ScalePressable>
</View>
) : null}
</Animated.View>
) : null}

Expand Down Expand Up @@ -565,6 +588,9 @@ const MapScene = memo(function MapScene({
{...commonMapProps}
provider="apple"
showsScale={scenario.advanced?.showsScale}
applePoiDetailPresentation={
scenario.advanced?.applePoiDetailPresentation
}
/>
);
}
Expand Down Expand Up @@ -655,18 +681,30 @@ export default function App() {
const [dockExpanded, setDockExpanded] = useState(false);
const [customMarkerRotation, setCustomMarkerRotation] = useState(45);
const [customMarkerFlat, setCustomMarkerFlat] = useState(true);
const [applePoiDetailPresentation, setApplePoiDetailPresentation] =
useState<ApplePoiDetailPresentation>(
APPLE_POI_DETAILS_DEFAULT_PRESENTATION,
);

const baseScenario = MAP_SCENARIOS[scenarioIndex];
const scenario = useMemo(() => {
if (baseScenario.id !== CUSTOM_MARKER_IMAGES_SCENARIO_ID) {
return baseScenario;
switch (baseScenario.id) {
case CUSTOM_MARKER_IMAGES_SCENARIO_ID:
return createCustomMarkerImagesScenario({
rotation: customMarkerRotation,
flat: customMarkerFlat,
});
case APPLE_POI_DETAILS_SCENARIO_ID:
return createApplePoiDetailsScenario(applePoiDetailPresentation);
default:
return baseScenario;
}

return createCustomMarkerImagesScenario({
rotation: customMarkerRotation,
flat: customMarkerFlat,
});
}, [baseScenario, customMarkerRotation, customMarkerFlat]);
}, [
baseScenario,
customMarkerRotation,
customMarkerFlat,
applePoiDetailPresentation,
]);
const provider = SUPPORTED_MAP_PROVIDERS[providerIndex] ?? 'google';
const animationOption = ANIMATION_OPTIONS[animationOptionIndex];
const showsScale = scenario.advanced?.showsScale === true;
Expand Down Expand Up @@ -721,19 +759,29 @@ export default function App() {
setCustomMarkerFlat((current) => !current);
}, []);

const cycleApplePoiDetailMode = useCallback(() => {
const next = nextApplePoiDetailPresentation(applePoiDetailPresentation);
setApplePoiDetailPresentation(next);
setStatus(
provider === 'apple'
? `POI details · ${next}`
: 'POI details · Apple Maps only',
);
}, [applePoiDetailPresentation, provider]);

const cycleProvider = useCallback(() => {
setProviderIndex((current) => {
if (SUPPORTED_MAP_PROVIDERS.length <= 1) {
setStatus(PROVIDER_LABELS[provider]);
return current;
}
// Keep the updater pure: React may run it twice, so status and ready are
// set from the handler with the index it computed.
if (SUPPORTED_MAP_PROVIDERS.length <= 1) {
setStatus(PROVIDER_LABELS[provider]);
return;
}

const next = (current + 1) % SUPPORTED_MAP_PROVIDERS.length;
setMapReady(false);
setStatus(PROVIDER_LABELS[SUPPORTED_MAP_PROVIDERS[next] ?? provider]);
return next;
});
}, [provider]);
const next = (providerIndex + 1) % SUPPORTED_MAP_PROVIDERS.length;
setProviderIndex(next);
setMapReady(false);
setStatus(PROVIDER_LABELS[SUPPORTED_MAP_PROVIDERS[next] ?? provider]);
}, [provider, providerIndex]);

const selectScenario = useCallback(
(index: number) => {
Expand Down Expand Up @@ -894,6 +942,7 @@ export default function App() {
customMarkerFlat={customMarkerFlat}
onCycleCustomMarkerRotation={cycleCustomMarkerRotation}
onToggleCustomMarkerFlat={toggleCustomMarkerFlat}
onCycleApplePoiDetailMode={cycleApplePoiDetailMode}
/>
<StatusBar style="light" />
</View>
Expand Down
56 changes: 56 additions & 0 deletions example/examples/applePoiDetails.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type {
ApplePoiDetailPresentation,
Region,
} from 'react-native-better-maps';
import type { MapScenario } from './types';

export const APPLE_POI_DETAILS_SCENARIO_ID = 'apple-poi-details';

export const APPLE_POI_DETAILS_DEFAULT_PRESENTATION: ApplePoiDetailPresentation =
'callout';

/** Cycle order for the presentation picker; exhaustive by construction. */
const NEXT_PRESENTATION: Record<
ApplePoiDetailPresentation,
ApplePoiDetailPresentation
> = {
automatic: 'callout',
callout: 'sheet',
sheet: 'openInMaps',
openInMaps: 'automatic',
};

export function nextApplePoiDetailPresentation(
current: ApplePoiDetailPresentation,
): ApplePoiDetailPresentation {
return NEXT_PRESENTATION[current];
}

/** Kraków's Main Square, dense with MapKit points of interest. */
const KRAKOW_MAIN_SQUARE: Region = {
latitude: 50.0617,
longitude: 19.9373,
latitudeDelta: 0.012,
longitudeDelta: 0.012,
};

/**
* Native MapKit place details (callout, sheet, Open in Maps). Apple Maps on
* iOS 18+ only; Google Maps stays event-only.
*/
export function createApplePoiDetailsScenario(
applePoiDetailPresentation: ApplePoiDetailPresentation,
): MapScenario {
return {
id: APPLE_POI_DETAILS_SCENARIO_ID,
name: 'Apple POI details',
description:
'Tap a place to open native MapKit details. Apple Maps on iOS 18+ only; Google Maps stays event-only.',
region: KRAKOW_MAIN_SQUARE,
advanced: { applePoiDetailPresentation },
};
}

export const applePoiDetailsScenario = createApplePoiDetailsScenario(
APPLE_POI_DETAILS_DEFAULT_PRESENTATION,
);
Loading