diff --git a/README.md b/README.md
index 2ab31d1..2ce69c7 100644
--- a/README.md
+++ b/README.md
@@ -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
`.
- **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.
@@ -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
+ {
+ 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:
@@ -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 |
@@ -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
diff --git a/docs/adr/0005-apple-native-poi-detail-presentation.md b/docs/adr/0005-apple-native-poi-detail-presentation.md
new file mode 100644
index 0000000..095e2d3
--- /dev/null
+++ b/docs/adr/0005-apple-native-poi-detail-presentation.md
@@ -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.
diff --git a/example/App.tsx b/example/App.tsx
index 09f74a2..ba8cb5c 100644
--- a/example/App.tsx
+++ b/example/App.tsx
@@ -39,6 +39,7 @@ import Animated, {
} from 'react-native-reanimated';
import {
MapView,
+ type ApplePoiDetailPresentation,
type Coordinate,
type EdgePadding,
type MapProvider,
@@ -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'];
@@ -294,6 +299,7 @@ type ScenarioDockProps = {
customMarkerFlat: boolean;
onCycleCustomMarkerRotation: () => void;
onToggleCustomMarkerFlat: () => void;
+ onCycleApplePoiDetailMode: () => void;
};
const ScenarioDock = memo(function ScenarioDock({
@@ -316,7 +322,10 @@ const ScenarioDock = memo(function ScenarioDock({
customMarkerFlat,
onCycleCustomMarkerRotation,
onToggleCustomMarkerFlat,
+ onCycleApplePoiDetailMode,
}: ScenarioDockProps) {
+ const applePoiDetailPresentation =
+ scenario.advanced?.applePoiDetailPresentation;
const chevronRotation = useSharedValue(0);
useEffect(() => {
@@ -455,6 +464,20 @@ const ScenarioDock = memo(function ScenarioDock({
) : null}
+
+ {applePoiDetailPresentation != null ? (
+
+
+ ◉
+
+ POI · {applePoiDetailPresentation}
+
+
+
+ ) : null}
) : null}
@@ -565,6 +588,9 @@ const MapScene = memo(function MapScene({
{...commonMapProps}
provider="apple"
showsScale={scenario.advanced?.showsScale}
+ applePoiDetailPresentation={
+ scenario.advanced?.applePoiDetailPresentation
+ }
/>
);
}
@@ -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(
+ 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;
@@ -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) => {
@@ -894,6 +942,7 @@ export default function App() {
customMarkerFlat={customMarkerFlat}
onCycleCustomMarkerRotation={cycleCustomMarkerRotation}
onToggleCustomMarkerFlat={toggleCustomMarkerFlat}
+ onCycleApplePoiDetailMode={cycleApplePoiDetailMode}
/>
diff --git a/example/examples/applePoiDetails.ts b/example/examples/applePoiDetails.ts
new file mode 100644
index 0000000..5a6e963
--- /dev/null
+++ b/example/examples/applePoiDetails.ts
@@ -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,
+);
diff --git a/example/examples/index.ts b/example/examples/index.ts
index 5de4ee3..f2c467a 100644
--- a/example/examples/index.ts
+++ b/example/examples/index.ts
@@ -1,5 +1,12 @@
import { advancedFeaturesScenario } from './advancedFeatures';
import { allOverlaysScenario } from './allOverlays';
+import {
+ APPLE_POI_DETAILS_DEFAULT_PRESENTATION,
+ APPLE_POI_DETAILS_SCENARIO_ID,
+ applePoiDetailsScenario,
+ createApplePoiDetailsScenario,
+ nextApplePoiDetailPresentation,
+} from './applePoiDetails';
import {
createCustomMarkerImagesScenario,
customMarkerImagesScenario,
@@ -14,9 +21,13 @@ import type { MapScenario } from './types';
export type { MapScenario } from './types';
export {
+ APPLE_POI_DETAILS_DEFAULT_PRESENTATION,
+ APPLE_POI_DETAILS_SCENARIO_ID,
+ createApplePoiDetailsScenario,
createCustomMarkerImagesScenario,
createScenarioOverlayProps,
CUSTOM_MARKER_IMAGES_SCENARIO_ID,
+ nextApplePoiDetailPresentation,
};
export const MAP_SCENARIOS: MapScenario[] = [
@@ -27,4 +38,5 @@ export const MAP_SCENARIOS: MapScenario[] = [
deliveryZoneScenario,
geojsonScenario,
advancedFeaturesScenario,
+ applePoiDetailsScenario,
];
diff --git a/example/examples/types.ts b/example/examples/types.ts
index b00afe8..0f456ed 100644
--- a/example/examples/types.ts
+++ b/example/examples/types.ts
@@ -1,4 +1,5 @@
import type {
+ ApplePoiDetailPresentation,
EdgePadding,
GeojsonInput,
GeojsonProps,
@@ -12,6 +13,7 @@ export interface MapScenarioAdvancedOptions {
followsUserLocation?: boolean;
showsCompass?: boolean;
showsScale?: boolean;
+ applePoiDetailPresentation?: ApplePoiDetailPresentation;
customMapStyle?: string;
mapPadding?: EdgePadding;
fitToCoordinatesOnReady?: boolean;
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 ef7f568..d060cc1 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
@@ -32,6 +32,7 @@ class HybridMapView(
private var _followsUserLocation: Boolean? = null
private var _showsCompass: Boolean? = null
private var _showsScale: Boolean? = null
+ private var _applePoiDetailPresentation: ApplePoiDetailPresentation? = null
private var _customMapStyle: String? = null
private var _googleMapId: String? = null
private var _clusteringEnabled: Boolean? = null
@@ -130,6 +131,13 @@ class HybridMapView(
adapter?.showsScale = value
}
+ /** Apple MapKit only; the Google Maps SDK has no native POI detail surface. */
+ override var applePoiDetailPresentation: ApplePoiDetailPresentation?
+ get() = _applePoiDetailPresentation
+ set(value) {
+ _applePoiDetailPresentation = value
+ }
+
override var customMapStyle: String?
get() = _customMapStyle
set(value) {
@@ -326,6 +334,7 @@ class HybridMapView(
_followsUserLocation = null
_showsCompass = null
_showsScale = null
+ _applePoiDetailPresentation = null
_customMapStyle = null
_googleMapId = null
_clusteringEnabled = null
diff --git a/package/ios/AppleMapProviderAdapter.swift b/package/ios/AppleMapProviderAdapter.swift
index fb73d5b..b466f60 100644
--- a/package/ios/AppleMapProviderAdapter.swift
+++ b/package/ios/AppleMapProviderAdapter.swift
@@ -162,6 +162,15 @@ final class AppleMapProviderAdapter: MapProviderAdapter {
applySelectablePoiFeatures(to: view)
}
}
+
+ /// Native MapKit detail presentation for selected POIs (iOS 18+). On iOS 18+ it enables
+ /// selectable points of interest on its own, independently of `onPoiPress`. On earlier
+ /// versions it is ignored and does not turn selection on.
+ var applePoiDetailPresentation: ApplePoiDetailPresentation? {
+ didSet {
+ applySelectablePoiFeatures(to: view)
+ }
+ }
var onLongPress: ((Coordinate) -> Void)?
var markers: [MarkerDescriptor]? {
@@ -411,6 +420,7 @@ final class AppleMapProviderAdapter: MapProviderAdapter {
onMapReady = nil
onPress = nil
onPoiPress = nil
+ applePoiDetailPresentation = nil
onLongPress = nil
onMarkerPress = nil
onMarkerDragEnd = nil
@@ -483,9 +493,21 @@ final class AppleMapProviderAdapter: MapProviderAdapter {
}
private func applySelectablePoiFeatures(to mapView: MKMapView) {
- if #available(iOS 16.0, *) {
- mapView.selectableMapFeatures = onPoiPress == nil ? [] : .pointsOfInterest
+ guard #available(iOS 16.0, *) else {
+ return
}
+
+ // Presentation accessories exist only on iOS 18+. Counting the prop on 16/17 would
+ // enable selection with nothing to show and can swallow the next background press.
+ let wantsNativeDetails: Bool
+ if #available(iOS 18.0, *) {
+ wantsNativeDetails = applePoiDetailPresentation != nil
+ } else {
+ wantsNativeDetails = false
+ }
+
+ let wantsSelectablePois = onPoiPress != nil || wantsNativeDetails
+ mapView.selectableMapFeatures = wantsSelectablePois ? .pointsOfInterest : []
}
}
diff --git a/package/ios/ApplePoiDetailPresentation+MKSelectionAccessory.swift b/package/ios/ApplePoiDetailPresentation+MKSelectionAccessory.swift
new file mode 100644
index 0000000..06cb1d4
--- /dev/null
+++ b/package/ios/ApplePoiDetailPresentation+MKSelectionAccessory.swift
@@ -0,0 +1,23 @@
+import MapKit
+import UIKit
+
+@available(iOS 18.0, *)
+extension ApplePoiDetailPresentation {
+ /// MapKit selection accessory for this presentation. `presenter` hosts sheets; without one,
+ /// `.sheet` degrades to a callout so the place details still show.
+ func toMKSelectionAccessory(presentedFrom presenter: UIViewController?) -> MKSelectionAccessory {
+ switch self {
+ case .automatic:
+ return .mapItemDetail(.automatic(presentationViewController: presenter))
+ case .callout:
+ return .mapItemDetail(.callout(.automatic))
+ case .sheet:
+ guard let presenter else {
+ return .mapItemDetail(.callout(.automatic))
+ }
+ return .mapItemDetail(.sheet(presentedFrom: presenter))
+ case .openinmaps:
+ return .mapItemDetail(.openInMaps)
+ }
+ }
+}
diff --git a/package/ios/GoogleMapProviderAdapter.swift b/package/ios/GoogleMapProviderAdapter.swift
index 591ad83..f431c23 100644
--- a/package/ios/GoogleMapProviderAdapter.swift
+++ b/package/ios/GoogleMapProviderAdapter.swift
@@ -136,6 +136,9 @@ final class GoogleMapProviderAdapter: NSObject, MapProviderAdapter {
var showsScale: Bool?
+ /// Apple MapKit only; the Google Maps SDK has no native POI detail surface.
+ var applePoiDetailPresentation: ApplePoiDetailPresentation?
+
var customMapStyle: String? {
didSet {
applyCustomMapStyle(to: view)
@@ -294,6 +297,7 @@ final class GoogleMapProviderAdapter: NSObject, MapProviderAdapter {
followsUserLocation = nil
showsCompass = nil
showsScale = nil
+ applePoiDetailPresentation = nil
customMapStyle = nil
googleMapId = nil
clusteringEnabled = nil
diff --git a/package/ios/HybridMapView.swift b/package/ios/HybridMapView.swift
index de8d26f..198e686 100644
--- a/package/ios/HybridMapView.swift
+++ b/package/ios/HybridMapView.swift
@@ -100,6 +100,15 @@ final class HybridMapView: HybridMapViewSpec {
set { setBackedOnMain(newValue, store: \.showsScale) { $0.showsScale = $1 } }
}
+ var applePoiDetailPresentation: ApplePoiDetailPresentation? {
+ get { getBacked(\.applePoiDetailPresentation) }
+ set {
+ setBackedOnMain(newValue, store: \.applePoiDetailPresentation) {
+ $0.applePoiDetailPresentation = $1
+ }
+ }
+ }
+
var customMapStyle: String? {
get { getBacked(\.customMapStyle) }
set { setBackedOnMain(newValue, store: \.customMapStyle) { $0.customMapStyle = $1 } }
diff --git a/package/ios/HybridMapViewDelegate.swift b/package/ios/HybridMapViewDelegate.swift
index 9303e8e..bca7bf4 100644
--- a/package/ios/HybridMapViewDelegate.swift
+++ b/package/ios/HybridMapViewDelegate.swift
@@ -165,7 +165,7 @@ final class HybridMapViewDelegate: NSObject, MKMapViewDelegate, UIGestureRecogni
func mapView(_ mapView: MKMapView, didSelect view: MKAnnotationView) {
if #available(iOS 16.0, *),
let mapFeature = view.annotation as? MKMapFeatureAnnotation,
- handleMapFeatureSelection(mapFeature)
+ handleMapFeatureSelection(mapFeature, in: mapView)
{
return
}
@@ -195,12 +195,34 @@ final class HybridMapViewDelegate: NSObject, MKMapViewDelegate, UIGestureRecogni
if #available(iOS 16.0, *),
let mapFeature = annotation as? MKMapFeatureAnnotation
{
- _ = handleMapFeatureSelection(mapFeature)
+ _ = handleMapFeatureSelection(mapFeature, in: mapView)
}
}
+ /// Supplies MapKit's native place details for selected POIs when
+ /// `applePoiDetailPresentation` is configured. MapKit keeps rendering its own
+ /// feature annotation view; only the selection accessory is provided here.
+ @available(iOS 18.0, *)
+ func mapView(
+ _ mapView: MKMapView,
+ selectionAccessoryFor annotation: MKAnnotation
+ ) -> MKSelectionAccessory? {
+ guard let mapFeature = annotation as? MKMapFeatureAnnotation,
+ mapFeature.featureType == .pointOfInterest
+ else {
+ return nil
+ }
+
+ return parent?.applePoiDetailPresentation?.toMKSelectionAccessory(
+ presentedFrom: mapView.nearestViewController
+ )
+ }
+
@available(iOS 16.0, *)
- private func handleMapFeatureSelection(_ mapFeature: MKMapFeatureAnnotation) -> Bool {
+ private func handleMapFeatureSelection(
+ _ mapFeature: MKMapFeatureAnnotation,
+ in mapView: MKMapView
+ ) -> Bool {
guard mapFeature.featureType == .pointOfInterest else {
return false
}
@@ -218,6 +240,13 @@ final class HybridMapViewDelegate: NSObject, MKMapViewDelegate, UIGestureRecogni
}
parent?.notifyPoiPress(annotation: mapFeature)
+
+ // MapKit shows the native details through the selection accessory (iOS 18+), which
+ // needs the POI to stay selected. Otherwise there is nothing to show, so clear it.
+ if #available(iOS 18.0, *), parent?.applePoiDetailPresentation != nil {
+ return true
+ }
+ mapView.deselectAnnotation(mapFeature, animated: false)
return true
}
diff --git a/package/ios/MapProviderAdapter.swift b/package/ios/MapProviderAdapter.swift
index ff5d220..8ab3d6c 100644
--- a/package/ios/MapProviderAdapter.swift
+++ b/package/ios/MapProviderAdapter.swift
@@ -15,6 +15,7 @@ protocol MapProviderAdapter: AnyObject {
var followsUserLocation: Bool? { get set }
var showsCompass: Bool? { get set }
var showsScale: Bool? { get set }
+ var applePoiDetailPresentation: ApplePoiDetailPresentation? { get set }
var customMapStyle: String? { get set }
var googleMapId: String? { get set }
var clusteringEnabled: Bool? { get set }
@@ -64,6 +65,7 @@ final class UnavailableMapProviderAdapter: MapProviderAdapter {
var followsUserLocation: Bool?
var showsCompass: Bool?
var showsScale: Bool?
+ var applePoiDetailPresentation: ApplePoiDetailPresentation?
var customMapStyle: String?
var googleMapId: String?
var clusteringEnabled: Bool?
diff --git a/package/ios/MapViewState.swift b/package/ios/MapViewState.swift
index 0793c9f..5e28f3b 100644
--- a/package/ios/MapViewState.swift
+++ b/package/ios/MapViewState.swift
@@ -13,6 +13,7 @@ struct MapViewState {
var followsUserLocation: Bool?
var showsCompass: Bool?
var showsScale: Bool?
+ var applePoiDetailPresentation: ApplePoiDetailPresentation?
var customMapStyle: String?
var googleMapId: String?
var clusteringEnabled: Bool?
@@ -48,6 +49,7 @@ struct MapViewState {
adapter.followsUserLocation = followsUserLocation
adapter.showsCompass = showsCompass
adapter.showsScale = showsScale
+ adapter.applePoiDetailPresentation = applePoiDetailPresentation
adapter.customMapStyle = customMapStyle
adapter.googleMapId = googleMapId
adapter.clusteringEnabled = clusteringEnabled
diff --git a/package/ios/UIView+NearestViewController.swift b/package/ios/UIView+NearestViewController.swift
new file mode 100644
index 0000000..89f35e0
--- /dev/null
+++ b/package/ios/UIView+NearestViewController.swift
@@ -0,0 +1,15 @@
+import UIKit
+
+extension UIView {
+ /// The closest view controller up the responder chain.
+ var nearestViewController: UIViewController? {
+ var responder: UIResponder? = next
+ while let current = responder {
+ if let controller = current as? UIViewController {
+ return controller
+ }
+ responder = current.next
+ }
+ return nil
+ }
+}
diff --git a/package/src/components/MapView.tsx b/package/src/components/MapView.tsx
index 132b08e..7b8e113 100644
--- a/package/src/components/MapView.tsx
+++ b/package/src/components/MapView.tsx
@@ -60,6 +60,7 @@ export function MapView({
followsUserLocation,
showsCompass,
showsScale,
+ applePoiDetailPresentation,
customMapStyle,
clusteringEnabled,
mapPadding,
@@ -286,6 +287,7 @@ export function MapView({
followsUserLocation={followsUserLocation}
showsCompass={showsCompass}
showsScale={showsScale}
+ applePoiDetailPresentation={applePoiDetailPresentation}
customMapStyle={customMapStyle}
clusteringEnabled={clusteringEnabled}
mapPadding={mapPadding}
diff --git a/package/src/index.ts b/package/src/index.ts
index be56c29..b7dadb1 100644
--- a/package/src/index.ts
+++ b/package/src/index.ts
@@ -15,6 +15,7 @@ export type {
EdgePadding,
VisibleRegion,
ApplePoiCategory,
+ ApplePoiDetailPresentation,
ApplePoiPressEvent,
GooglePoiPressEvent,
MapProvider,
diff --git a/package/src/native/specs/MapView.nitro.ts b/package/src/native/specs/MapView.nitro.ts
index 0522c39..90b80ee 100644
--- a/package/src/native/specs/MapView.nitro.ts
+++ b/package/src/native/specs/MapView.nitro.ts
@@ -93,6 +93,14 @@ export type ApplePoiCategory =
| 'zoo'
| 'unknown';
+/**
+ * Native MapKit presentation for a selected point of interest (iOS 18+).
+ *
+ * Mirrors the `MKSelectionAccessory.mapItemDetail(_:)` presentation styles.
+ */
+export type ApplePoiDetailPresentation =
+ 'automatic' | 'callout' | 'sheet' | 'openInMaps';
+
export interface NativePoiPressEvent {
provider: MapProvider;
coordinate: Coordinate;
@@ -151,6 +159,12 @@ export interface MapViewProps extends HybridViewProps {
/** Whether to show the scale control (iOS only). */
showsScale?: boolean;
+ /**
+ * Native MapKit detail presentation for selected points of interest
+ * (Apple MapKit only, iOS 18+). Omit to disable.
+ */
+ applePoiDetailPresentation?: ApplePoiDetailPresentation;
+
/** Custom map style as a JSON string (full support on Android; curated subset on iOS 16+). */
customMapStyle?: string;
diff --git a/package/src/types/index.ts b/package/src/types/index.ts
index b6b344d..5a7e0bb 100644
--- a/package/src/types/index.ts
+++ b/package/src/types/index.ts
@@ -1,7 +1,10 @@
export type { Coordinate } from './coordinate';
export type { Camera } from './camera';
export type { Region, EdgePadding, VisibleRegion } from './region';
-export type { ApplePoiCategory } from '../native/specs/MapView.nitro';
+export type {
+ ApplePoiCategory,
+ ApplePoiDetailPresentation,
+} from '../native/specs/MapView.nitro';
export type {
ApplePoiPressEvent,
GooglePoiPressEvent,
diff --git a/package/src/types/map.ts b/package/src/types/map.ts
index 7d96db7..f799374 100644
--- a/package/src/types/map.ts
+++ b/package/src/types/map.ts
@@ -7,7 +7,10 @@ import type {
PolygonDescriptor,
PolylineDescriptor,
} from '../native/specs/overlays';
-import type { ApplePoiCategory } from '../native/specs/MapView.nitro';
+import type {
+ ApplePoiCategory,
+ ApplePoiDetailPresentation,
+} from '../native/specs/MapView.nitro';
import type { MarkerDescriptor, OverlayEnteringAnimation } from './overlays';
import type { EdgePadding, Region } from './region';
@@ -140,6 +143,12 @@ interface ExistingDefaultProviderProps extends BaseMapViewProps {
/** Whether to show the scale control (supported by Apple MapKit). */
showsScale?: boolean;
+ /**
+ * Native MapKit detail presentation for selected points of interest.
+ * Apple MapKit on iOS 18+ only; a no-op elsewhere.
+ */
+ applePoiDetailPresentation?: ApplePoiDetailPresentation;
+
/** Custom map style as a JSON string (full support on Google Maps; curated subset on Apple MapKit iOS 16+). */
customMapStyle?: string;
@@ -159,6 +168,12 @@ interface AppleMapViewProps extends BaseMapViewProps {
/** Whether to show the scale control. */
showsScale?: boolean;
+ /**
+ * Presents native MapKit details for a selected point of interest on
+ * iOS 18+. Works with or without `onPoiPress`. Omit to disable.
+ */
+ applePoiDetailPresentation?: ApplePoiDetailPresentation;
+
/** Custom map style as a JSON string. Apple MapKit applies a curated subset on iOS 16+. */
customMapStyle?: string;
@@ -178,6 +193,9 @@ interface GoogleMapViewProps extends BaseMapViewProps {
/** Google Maps SDK has no native scale control. */
showsScale?: never;
+ /** Google Maps SDK has no native POI detail surface; POI taps stay event-only. */
+ applePoiDetailPresentation?: never;
+
/** Custom Google Maps style JSON. */
customMapStyle?: string;
@@ -192,6 +210,7 @@ interface OpenStreetMapViewProps extends BaseMapViewProps {
provider: 'openstreetmap';
googleMapId?: never;
showsScale?: never;
+ applePoiDetailPresentation?: never;
customMapStyle?: never;
clusteringEnabled?: never;
clusterEnteringAnimation?: never;
@@ -202,6 +221,7 @@ interface MapboxMapViewProps extends BaseMapViewProps {
provider: 'mapbox';
googleMapId?: never;
showsScale?: never;
+ applePoiDetailPresentation?: never;
customMapStyle?: never;
clusteringEnabled?: never;
clusterEnteringAnimation?: never;
diff --git a/package/type-tests/provider-props.ts b/package/type-tests/provider-props.ts
index 1cef74a..843a4ac 100644
--- a/package/type-tests/provider-props.ts
+++ b/package/type-tests/provider-props.ts
@@ -7,6 +7,7 @@ import type {
export const appleProps: MapViewPropsForProvider<'apple'> = {
provider: 'apple',
showsScale: true,
+ applePoiDetailPresentation: 'callout',
clusteringEnabled: true,
markerEnteringAnimation: { preset: 'fade-scale', duration: 180 },
clusterEnteringAnimation: 'system',
@@ -32,6 +33,7 @@ export const googleProps: MapViewPropsForProvider<'google'> = {
export const defaultProviderProps: MapViewProps = {
showsScale: true,
+ applePoiDetailPresentation: 'sheet',
customMapStyle: '[]',
markerEnteringAnimation: false,
onPoiPress: (event) => {
@@ -103,3 +105,22 @@ export const mapboxPoiPressProps: MapViewPropsForProvider<'mapbox'> = {
// @ts-expect-error Planned Mapbox support has no native POI press capability yet.
onPoiPress: () => {},
};
+
+export const googleApplePoiDetailProps: MapViewPropsForProvider<'google'> = {
+ provider: 'google',
+ // @ts-expect-error Google Maps has no native POI detail surface; POI taps stay event-only.
+ applePoiDetailPresentation: 'callout',
+};
+
+export const openStreetMapApplePoiDetailProps: MapViewPropsForProvider<'openstreetmap'> =
+ {
+ provider: 'openstreetmap',
+ // @ts-expect-error Planned OpenStreetMap support has no native POI detail surface.
+ applePoiDetailPresentation: 'callout',
+ };
+
+export const mapboxApplePoiDetailProps: MapViewPropsForProvider<'mapbox'> = {
+ provider: 'mapbox',
+ // @ts-expect-error Planned Mapbox support has no native POI detail surface.
+ applePoiDetailPresentation: 'callout',
+};