From 62a21e3ff83e5d58aacdfef57983b56330131493 Mon Sep 17 00:00:00 2001 From: Jakub Kasprzyk Date: Tue, 15 Sep 2026 14:45:44 +0200 Subject: [PATCH 01/11] feat(ios): present native MapKit POI details via MKSelectionAccessory Add the applePoiDetailPresentation Nitro prop ('automatic' | 'callout' | 'sheet' | 'openInMaps'). On iOS 18+ the Apple adapter answers mapView(_:selectionAccessoryFor:) with MKSelectionAccessory.mapItemDetail so MapKit shows its own place details for selected POIs. The prop enables selectableMapFeatures on its own, independently of onPoiPress. Without a presentation (or on iOS 16/17) the POI is deselected right after onPoiPress fires, as #33 specified. The Google adapters store the value and ignore it. --- .../margelo/nitro/nitromaps/HybridMapView.kt | 8 +++ package/ios/AppleMapProviderAdapter.swift | 65 ++++++++++++++++++- package/ios/GoogleMapProviderAdapter.swift | 4 ++ package/ios/HybridMapView.swift | 9 +++ package/ios/HybridMapViewDelegate.swift | 32 ++++++++- package/ios/MapProviderAdapter.swift | 2 + package/ios/MapViewState.swift | 2 + package/src/native/specs/MapView.nitro.ts | 14 ++++ 8 files changed, 132 insertions(+), 4 deletions(-) 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..e2d112f 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) { diff --git a/package/ios/AppleMapProviderAdapter.swift b/package/ios/AppleMapProviderAdapter.swift index fb73d5b..8f83913 100644 --- a/package/ios/AppleMapProviderAdapter.swift +++ b/package/ios/AppleMapProviderAdapter.swift @@ -162,6 +162,14 @@ final class AppleMapProviderAdapter: MapProviderAdapter { applySelectablePoiFeatures(to: view) } } + + /// Native MapKit detail presentation for selected POIs (iOS 18+). Enables selectable + /// points of interest on its own, independently of `onPoiPress`. + var applePoiDetailPresentation: ApplePoiDetailPresentation? { + didSet { + applySelectablePoiFeatures(to: view) + } + } var onLongPress: ((Coordinate) -> Void)? var markers: [MarkerDescriptor]? { @@ -411,6 +419,7 @@ final class AppleMapProviderAdapter: MapProviderAdapter { onMapReady = nil onPress = nil onPoiPress = nil + applePoiDetailPresentation = nil onLongPress = nil onMarkerPress = nil onMarkerDragEnd = nil @@ -484,8 +493,62 @@ final class AppleMapProviderAdapter: MapProviderAdapter { private func applySelectablePoiFeatures(to mapView: MKMapView) { if #available(iOS 16.0, *) { - mapView.selectableMapFeatures = onPoiPress == nil ? [] : .pointsOfInterest + let wantsSelectablePois = onPoiPress != nil || applePoiDetailPresentation != nil + mapView.selectableMapFeatures = wantsSelectablePois ? .pointsOfInterest : [] } } + /// Whether a tapped POI must stay selected so MapKit can show its native details. + /// False when no presentation is configured or the OS predates `MKSelectionAccessory`. + var presentsNativePoiDetails: Bool { + guard applePoiDetailPresentation != nil else { + return false + } + if #available(iOS 18.0, *) { + return true + } + return false + } + + /// Selection accessory for a POI feature annotation, mirroring `applePoiDetailPresentation`. + @available(iOS 18.0, *) + func poiSelectionAccessory() -> MKSelectionAccessory? { + guard let applePoiDetailPresentation else { + return nil + } + + let presenter = view.nearestViewController + let style: MKSelectionAccessory.MapItemDetailPresentationStyle + switch applePoiDetailPresentation { + case .automatic: + style = .automatic(presentationViewController: presenter) + case .callout: + style = .callout(.automatic) + case .sheet: + if let presenter { + style = .sheet(presentedFrom: presenter) + } else { + // Nothing can present a sheet yet; let MapKit pick a presentation instead. + style = .automatic(presentationViewController: nil) + } + case .openinmaps: + style = .openInMaps + } + return .mapItemDetail(style) + } + +} + +extension UIView { + /// The closest view controller up the responder chain, used to present MapKit sheets. + fileprivate 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/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..d9c40fe 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,32 @@ 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?.poiSelectionAccessory() + } + @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 +238,12 @@ final class HybridMapViewDelegate: NSObject, MKMapViewDelegate, UIGestureRecogni } parent?.notifyPoiPress(annotation: mapFeature) + + // Without native details there is nothing to show for a selected POI, so clear + // the selection right away. With details, MapKit needs the selection to stay. + if parent?.presentsNativePoiDetails != 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/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; From e9b9199447bdc35f6b44459f9ee5d62e570c8d1a Mon Sep 17 00:00:00 2001 From: Jakub Kasprzyk Date: Tue, 15 Sep 2026 14:45:44 +0200 Subject: [PATCH 02/11] feat(types): add applePoiDetailPresentation prop and provider type tests Expose ApplePoiDetailPresentation and accept the prop for provider="apple" and the omitted provider; reject it with never on google, openstreetmap and mapbox, following the googleMapId/showsScale convention. --- package/src/components/MapView.tsx | 2 ++ package/src/index.ts | 1 + package/src/types/index.ts | 5 ++++- package/src/types/map.ts | 22 +++++++++++++++++++++- package/type-tests/provider-props.ts | 21 +++++++++++++++++++++ 5 files changed, 49 insertions(+), 2 deletions(-) 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/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', +}; From c94fcb235bdbc89b998c39afe7d4f088ef6112fa Mon Sep 17 00:00:00 2001 From: Jakub Kasprzyk Date: Tue, 15 Sep 2026 14:45:44 +0200 Subject: [PATCH 03/11] feat(example): add Apple POI details scenario with presentation picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New 'Apple POI details' scenario around Kraków's Main Square plus a dock chip that cycles automatic/callout/sheet/openInMaps, kept separate from the event-only POI logging. --- example/App.tsx | 65 +++++++++++++++++++++++++++++ example/examples/applePoiDetails.ts | 23 ++++++++++ example/examples/index.ts | 6 +++ example/examples/types.ts | 2 + 4 files changed, 96 insertions(+) create mode 100644 example/examples/applePoiDetails.ts diff --git a/example/App.tsx b/example/App.tsx index 09f74a2..5970d2d 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,6 +50,7 @@ import { Region, } from 'react-native-better-maps'; import { + APPLE_POI_DETAILS_SCENARIO_ID, MAP_SCENARIOS, type MapScenario, createCustomMarkerImagesScenario, @@ -73,6 +75,23 @@ const PROVIDER_LABELS: Record = { const SUPPORTED_MAP_PROVIDERS = getSupportedMapProviders(); +/** Native MapKit POI detail modes cycled by the Apple POI details scenario. */ +const APPLE_POI_DETAIL_MODES: ApplePoiDetailPresentation[] = [ + 'automatic', + 'callout', + 'sheet', + 'openInMaps', +]; + +function getInitialApplePoiDetailModeIndex(): number { + const scenarioMode = MAP_SCENARIOS.find( + (scenario) => scenario.id === APPLE_POI_DETAILS_SCENARIO_ID, + )?.advanced?.applePoiDetailPresentation; + const index = + scenarioMode == null ? -1 : APPLE_POI_DETAIL_MODES.indexOf(scenarioMode); + return index >= 0 ? index : 0; +} + const ANIMATION_OPTIONS: AnimationOption[] = [ { id: 'system', label: 'System', value: 'system' }, { id: 'fade', label: 'Fade', value: { preset: 'fade', duration: 180 } }, @@ -294,6 +313,8 @@ type ScenarioDockProps = { customMarkerFlat: boolean; onCycleCustomMarkerRotation: () => void; onToggleCustomMarkerFlat: () => void; + applePoiDetailMode: ApplePoiDetailPresentation; + onCycleApplePoiDetailMode: () => void; }; const ScenarioDock = memo(function ScenarioDock({ @@ -316,6 +337,8 @@ const ScenarioDock = memo(function ScenarioDock({ customMarkerFlat, onCycleCustomMarkerRotation, onToggleCustomMarkerFlat, + applePoiDetailMode, + onCycleApplePoiDetailMode, }: ScenarioDockProps) { const chevronRotation = useSharedValue(0); @@ -455,6 +478,20 @@ const ScenarioDock = memo(function ScenarioDock({ ) : null} + + {scenario.id === APPLE_POI_DETAILS_SCENARIO_ID ? ( + + + + + POI · {applePoiDetailMode} + + + + ) : null} ) : null} @@ -498,6 +535,7 @@ type MapSceneProps = { mapType: MapType; mapPadding?: EdgePadding; animationOption: AnimationOption; + applePoiDetailPresentation?: ApplePoiDetailPresentation; onMapReady: () => void; onClusterPress: (markerIds: string[], coordinate: Coordinate) => void; onMarkerPress: (id: string) => void; @@ -517,6 +555,7 @@ const MapScene = memo(function MapScene({ mapType, mapPadding, animationOption, + applePoiDetailPresentation, onMapReady, onClusterPress, onMarkerPress, @@ -565,6 +604,7 @@ const MapScene = memo(function MapScene({ {...commonMapProps} provider="apple" showsScale={scenario.advanced?.showsScale} + applePoiDetailPresentation={applePoiDetailPresentation} /> ); } @@ -655,6 +695,9 @@ export default function App() { const [dockExpanded, setDockExpanded] = useState(false); const [customMarkerRotation, setCustomMarkerRotation] = useState(45); const [customMarkerFlat, setCustomMarkerFlat] = useState(true); + const [applePoiDetailModeIndex, setApplePoiDetailModeIndex] = useState( + getInitialApplePoiDetailModeIndex, + ); const baseScenario = MAP_SCENARIOS[scenarioIndex]; const scenario = useMemo(() => { @@ -669,6 +712,12 @@ export default function App() { }, [baseScenario, customMarkerRotation, customMarkerFlat]); const provider = SUPPORTED_MAP_PROVIDERS[providerIndex] ?? 'google'; const animationOption = ANIMATION_OPTIONS[animationOptionIndex]; + const applePoiDetailMode = + APPLE_POI_DETAIL_MODES[applePoiDetailModeIndex] ?? 'callout'; + const applePoiDetailPresentation = + scenario.id === APPLE_POI_DETAILS_SCENARIO_ID + ? applePoiDetailMode + : scenario.advanced?.applePoiDetailPresentation; const showsScale = scenario.advanced?.showsScale === true; const showsCompass = scenario.advanced?.showsCompass === true; const mapPadding = useMemo( @@ -721,6 +770,19 @@ export default function App() { setCustomMarkerFlat((current) => !current); }, []); + const cycleApplePoiDetailMode = useCallback(() => { + if (provider !== 'apple') { + setStatus('POI details · Apple Maps only'); + return; + } + + setApplePoiDetailModeIndex((current) => { + const next = (current + 1) % APPLE_POI_DETAIL_MODES.length; + setStatus(`POI details · ${APPLE_POI_DETAIL_MODES[next]}`); + return next; + }); + }, [provider]); + const cycleProvider = useCallback(() => { setProviderIndex((current) => { if (SUPPORTED_MAP_PROVIDERS.length <= 1) { @@ -855,6 +917,7 @@ export default function App() { mapType={MAP_TYPES[mapTypeIndex]} mapPadding={mapPadding} animationOption={animationOption} + applePoiDetailPresentation={applePoiDetailPresentation} onMapReady={handleMapReady} onClusterPress={handleClusterPress} onMarkerPress={handleMarkerPress} @@ -894,6 +957,8 @@ export default function App() { customMarkerFlat={customMarkerFlat} onCycleCustomMarkerRotation={cycleCustomMarkerRotation} onToggleCustomMarkerFlat={toggleCustomMarkerFlat} + applePoiDetailMode={applePoiDetailMode} + onCycleApplePoiDetailMode={cycleApplePoiDetailMode} /> diff --git a/example/examples/applePoiDetails.ts b/example/examples/applePoiDetails.ts new file mode 100644 index 0000000..3908377 --- /dev/null +++ b/example/examples/applePoiDetails.ts @@ -0,0 +1,23 @@ +import type { MapScenario } from './types'; + +export const APPLE_POI_DETAILS_SCENARIO_ID = 'apple-poi-details'; + +/** + * Native MapKit place details (callout, sheet, Open in Maps) around Kraków's + * Main Square. Apple Maps on iOS 18+ only; Google Maps stays event-only. + */ +export const applePoiDetailsScenario: MapScenario = { + 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: { + latitude: 50.0617, + longitude: 19.9373, + latitudeDelta: 0.012, + longitudeDelta: 0.012, + }, + advanced: { + applePoiDetailPresentation: 'callout', + }, +}; diff --git a/example/examples/index.ts b/example/examples/index.ts index 5de4ee3..94d4dd3 100644 --- a/example/examples/index.ts +++ b/example/examples/index.ts @@ -1,5 +1,9 @@ import { advancedFeaturesScenario } from './advancedFeatures'; import { allOverlaysScenario } from './allOverlays'; +import { + APPLE_POI_DETAILS_SCENARIO_ID, + applePoiDetailsScenario, +} from './applePoiDetails'; import { createCustomMarkerImagesScenario, customMarkerImagesScenario, @@ -14,6 +18,7 @@ import type { MapScenario } from './types'; export type { MapScenario } from './types'; export { + APPLE_POI_DETAILS_SCENARIO_ID, createCustomMarkerImagesScenario, createScenarioOverlayProps, CUSTOM_MARKER_IMAGES_SCENARIO_ID, @@ -27,4 +32,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; From c3b603001ae47d92e544bd2f8768f26dbb5c80b5 Mon Sep 17 00:00:00 2001 From: Jakub Kasprzyk Date: Tue, 15 Sep 2026 14:45:45 +0200 Subject: [PATCH 04/11] docs: describe Apple native POI details and add ADR 0005 --- README.md | 82 +++++++++++++------ ...05-apple-native-poi-detail-presentation.md | 54 ++++++++++++ 2 files changed, 110 insertions(+), 26 deletions(-) create mode 100644 docs/adr/0005-apple-native-poi-detail-presentation.md diff --git a/README.md b/README.md index 2ab31d1..08e2792 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`. + +```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 presented from the map's view controller | +| `'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..d7e8baf --- /dev/null +++ b/docs/adr/0005-apple-native-poi-detail-presentation.md @@ -0,0 +1,54 @@ +# 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`. +- React Native POI detail components, custom callout content, and cross-provider parity remain + out of scope. From b644d78042c47562223a7cbab454a18e58c8d79d Mon Sep 17 00:00:00 2001 From: Jakub Kasprzyk Date: Tue, 15 Sep 2026 15:51:29 +0200 Subject: [PATCH 05/11] fix(docs): restore escaped pipes in the README types table The table re-alignment split the escaped `\|` inside union-type cells into extra columns, breaking the MapType, MapProvider and ApplePoiDetailPresentation rows. Rebuild the table from main with only the new row added. --- README.md | 54 +++++++++++++++++++++++++++--------------------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 08e2792..1fc391a 100644 --- a/README.md +++ b/README.md @@ -607,33 +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` | -| `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` | +| 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 From bd01eaf4fc7ada54d9a399cec4b04602307a46c4 Mon Sep 17 00:00:00 2001 From: Jakub Kasprzyk Date: Tue, 15 Sep 2026 15:51:30 +0200 Subject: [PATCH 06/11] fix(android): reset applePoiDetailPresentation when the view is recycled prepareForRecycle() clears every stored prop; the new field was missing from that list. --- .../src/main/java/com/margelo/nitro/nitromaps/HybridMapView.kt | 1 + 1 file changed, 1 insertion(+) 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 e2d112f..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 @@ -334,6 +334,7 @@ class HybridMapView( _followsUserLocation = null _showsCompass = null _showsScale = null + _applePoiDetailPresentation = null _customMapStyle = null _googleMapId = null _clusteringEnabled = null From 6868e2d78e3b9ff9913c3728cb83dc5f0a2c186a Mon Sep 17 00:00:00 2001 From: Jakub Kasprzyk Date: Tue, 15 Sep 2026 15:51:30 +0200 Subject: [PATCH 07/11] refactor(ios): map the POI detail presentation to MapKit in an enum extension Move the MKSelectionAccessory conversion into ApplePoiDetailPresentation+MKSelectionAccessory.swift, next to the other Type+MKType extensions, and the responder-chain walk into UIView+NearestViewController.swift. The delegate reads the stored prop directly, so the adapter no longer exposes presentsNativePoiDetails and poiSelectionAccessory(). A sheet without a presenting view controller now degrades explicitly to a callout. --- package/ios/AppleMapProviderAdapter.swift | 53 ------------------- ...ailPresentation+MKSelectionAccessory.swift | 23 ++++++++ package/ios/HybridMapViewDelegate.swift | 13 +++-- .../ios/UIView+NearestViewController.swift | 15 ++++++ 4 files changed, 46 insertions(+), 58 deletions(-) create mode 100644 package/ios/ApplePoiDetailPresentation+MKSelectionAccessory.swift create mode 100644 package/ios/UIView+NearestViewController.swift diff --git a/package/ios/AppleMapProviderAdapter.swift b/package/ios/AppleMapProviderAdapter.swift index 8f83913..b59c5fe 100644 --- a/package/ios/AppleMapProviderAdapter.swift +++ b/package/ios/AppleMapProviderAdapter.swift @@ -498,57 +498,4 @@ final class AppleMapProviderAdapter: MapProviderAdapter { } } - /// Whether a tapped POI must stay selected so MapKit can show its native details. - /// False when no presentation is configured or the OS predates `MKSelectionAccessory`. - var presentsNativePoiDetails: Bool { - guard applePoiDetailPresentation != nil else { - return false - } - if #available(iOS 18.0, *) { - return true - } - return false - } - - /// Selection accessory for a POI feature annotation, mirroring `applePoiDetailPresentation`. - @available(iOS 18.0, *) - func poiSelectionAccessory() -> MKSelectionAccessory? { - guard let applePoiDetailPresentation else { - return nil - } - - let presenter = view.nearestViewController - let style: MKSelectionAccessory.MapItemDetailPresentationStyle - switch applePoiDetailPresentation { - case .automatic: - style = .automatic(presentationViewController: presenter) - case .callout: - style = .callout(.automatic) - case .sheet: - if let presenter { - style = .sheet(presentedFrom: presenter) - } else { - // Nothing can present a sheet yet; let MapKit pick a presentation instead. - style = .automatic(presentationViewController: nil) - } - case .openinmaps: - style = .openInMaps - } - return .mapItemDetail(style) - } - -} - -extension UIView { - /// The closest view controller up the responder chain, used to present MapKit sheets. - fileprivate 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/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/HybridMapViewDelegate.swift b/package/ios/HybridMapViewDelegate.swift index d9c40fe..bca7bf4 100644 --- a/package/ios/HybridMapViewDelegate.swift +++ b/package/ios/HybridMapViewDelegate.swift @@ -213,7 +213,9 @@ final class HybridMapViewDelegate: NSObject, MKMapViewDelegate, UIGestureRecogni return nil } - return parent?.poiSelectionAccessory() + return parent?.applePoiDetailPresentation?.toMKSelectionAccessory( + presentedFrom: mapView.nearestViewController + ) } @available(iOS 16.0, *) @@ -239,11 +241,12 @@ final class HybridMapViewDelegate: NSObject, MKMapViewDelegate, UIGestureRecogni parent?.notifyPoiPress(annotation: mapFeature) - // Without native details there is nothing to show for a selected POI, so clear - // the selection right away. With details, MapKit needs the selection to stay. - if parent?.presentsNativePoiDetails != true { - mapView.deselectAnnotation(mapFeature, animated: false) + // 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/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 + } +} From 96c84e198089224a24bd4ce03428a7fa573a1c1b Mon Sep 17 00:00:00 2001 From: Jakub Kasprzyk Date: Tue, 15 Sep 2026 15:51:30 +0200 Subject: [PATCH 08/11] refactor(example): let the Apple POI details scenario own its presentation createApplePoiDetailsScenario(presentation) builds the scenario for the current mode inside the existing scenario memo, so MapScene and the dock read scenario.advanced.applePoiDetailPresentation like showsScale. The cycle order lives in the scenario module as an exhaustive Record; App.tsx drops the mode list, the index state, the derived values and two scenario-id checks. --- example/App.tsx | 92 ++++++++++++----------------- example/examples/applePoiDetails.ts | 67 +++++++++++++++------ example/examples/index.ts | 6 ++ 3 files changed, 94 insertions(+), 71 deletions(-) diff --git a/example/App.tsx b/example/App.tsx index 5970d2d..f472db8 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -50,12 +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']; @@ -75,23 +78,6 @@ const PROVIDER_LABELS: Record = { const SUPPORTED_MAP_PROVIDERS = getSupportedMapProviders(); -/** Native MapKit POI detail modes cycled by the Apple POI details scenario. */ -const APPLE_POI_DETAIL_MODES: ApplePoiDetailPresentation[] = [ - 'automatic', - 'callout', - 'sheet', - 'openInMaps', -]; - -function getInitialApplePoiDetailModeIndex(): number { - const scenarioMode = MAP_SCENARIOS.find( - (scenario) => scenario.id === APPLE_POI_DETAILS_SCENARIO_ID, - )?.advanced?.applePoiDetailPresentation; - const index = - scenarioMode == null ? -1 : APPLE_POI_DETAIL_MODES.indexOf(scenarioMode); - return index >= 0 ? index : 0; -} - const ANIMATION_OPTIONS: AnimationOption[] = [ { id: 'system', label: 'System', value: 'system' }, { id: 'fade', label: 'Fade', value: { preset: 'fade', duration: 180 } }, @@ -313,7 +299,6 @@ type ScenarioDockProps = { customMarkerFlat: boolean; onCycleCustomMarkerRotation: () => void; onToggleCustomMarkerFlat: () => void; - applePoiDetailMode: ApplePoiDetailPresentation; onCycleApplePoiDetailMode: () => void; }; @@ -337,9 +322,10 @@ const ScenarioDock = memo(function ScenarioDock({ customMarkerFlat, onCycleCustomMarkerRotation, onToggleCustomMarkerFlat, - applePoiDetailMode, onCycleApplePoiDetailMode, }: ScenarioDockProps) { + const applePoiDetailPresentation = + scenario.advanced?.applePoiDetailPresentation; const chevronRotation = useSharedValue(0); useEffect(() => { @@ -479,7 +465,7 @@ const ScenarioDock = memo(function ScenarioDock({ ) : null} - {scenario.id === APPLE_POI_DETAILS_SCENARIO_ID ? ( + {applePoiDetailPresentation != null ? ( - POI · {applePoiDetailMode} + POI · {applePoiDetailPresentation} @@ -535,7 +521,6 @@ type MapSceneProps = { mapType: MapType; mapPadding?: EdgePadding; animationOption: AnimationOption; - applePoiDetailPresentation?: ApplePoiDetailPresentation; onMapReady: () => void; onClusterPress: (markerIds: string[], coordinate: Coordinate) => void; onMarkerPress: (id: string) => void; @@ -555,7 +540,6 @@ const MapScene = memo(function MapScene({ mapType, mapPadding, animationOption, - applePoiDetailPresentation, onMapReady, onClusterPress, onMarkerPress, @@ -604,7 +588,9 @@ const MapScene = memo(function MapScene({ {...commonMapProps} provider="apple" showsScale={scenario.advanced?.showsScale} - applePoiDetailPresentation={applePoiDetailPresentation} + applePoiDetailPresentation={ + scenario.advanced?.applePoiDetailPresentation + } /> ); } @@ -695,29 +681,32 @@ export default function App() { const [dockExpanded, setDockExpanded] = useState(false); const [customMarkerRotation, setCustomMarkerRotation] = useState(45); const [customMarkerFlat, setCustomMarkerFlat] = useState(true); - const [applePoiDetailModeIndex, setApplePoiDetailModeIndex] = useState( - getInitialApplePoiDetailModeIndex, - ); + 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 applePoiDetailMode = - APPLE_POI_DETAIL_MODES[applePoiDetailModeIndex] ?? 'callout'; - const applePoiDetailPresentation = - scenario.id === APPLE_POI_DETAILS_SCENARIO_ID - ? applePoiDetailMode - : scenario.advanced?.applePoiDetailPresentation; const showsScale = scenario.advanced?.showsScale === true; const showsCompass = scenario.advanced?.showsCompass === true; const mapPadding = useMemo( @@ -771,17 +760,14 @@ export default function App() { }, []); const cycleApplePoiDetailMode = useCallback(() => { - if (provider !== 'apple') { - setStatus('POI details · Apple Maps only'); - return; - } - - setApplePoiDetailModeIndex((current) => { - const next = (current + 1) % APPLE_POI_DETAIL_MODES.length; - setStatus(`POI details · ${APPLE_POI_DETAIL_MODES[next]}`); - return next; - }); - }, [provider]); + const next = nextApplePoiDetailPresentation(applePoiDetailPresentation); + setApplePoiDetailPresentation(next); + setStatus( + provider === 'apple' + ? `POI details · ${next}` + : 'POI details · Apple Maps only', + ); + }, [applePoiDetailPresentation, provider]); const cycleProvider = useCallback(() => { setProviderIndex((current) => { @@ -917,7 +903,6 @@ export default function App() { mapType={MAP_TYPES[mapTypeIndex]} mapPadding={mapPadding} animationOption={animationOption} - applePoiDetailPresentation={applePoiDetailPresentation} onMapReady={handleMapReady} onClusterPress={handleClusterPress} onMarkerPress={handleMarkerPress} @@ -957,7 +942,6 @@ export default function App() { customMarkerFlat={customMarkerFlat} onCycleCustomMarkerRotation={cycleCustomMarkerRotation} onToggleCustomMarkerFlat={toggleCustomMarkerFlat} - applePoiDetailMode={applePoiDetailMode} onCycleApplePoiDetailMode={cycleApplePoiDetailMode} /> diff --git a/example/examples/applePoiDetails.ts b/example/examples/applePoiDetails.ts index 3908377..5a6e963 100644 --- a/example/examples/applePoiDetails.ts +++ b/example/examples/applePoiDetails.ts @@ -1,23 +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) around Kraków's - * Main Square. Apple Maps on iOS 18+ only; Google Maps stays event-only. + * Native MapKit place details (callout, sheet, Open in Maps). Apple Maps on + * iOS 18+ only; Google Maps stays event-only. */ -export const applePoiDetailsScenario: MapScenario = { - 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: { - latitude: 50.0617, - longitude: 19.9373, - latitudeDelta: 0.012, - longitudeDelta: 0.012, - }, - advanced: { - applePoiDetailPresentation: 'callout', - }, -}; +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 94d4dd3..f2c467a 100644 --- a/example/examples/index.ts +++ b/example/examples/index.ts @@ -1,8 +1,11 @@ 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, @@ -18,10 +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[] = [ From dfd27b57049c107cf135217dc3a4f32fb181e700 Mon Sep 17 00:00:00 2001 From: Jakub Kasprzyk Date: Tue, 15 Sep 2026 16:26:18 +0200 Subject: [PATCH 09/11] docs: list every provider that rejects applePoiDetailPresentation --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1fc391a..9937c7e 100644 --- a/README.md +++ b/README.md @@ -311,7 +311,7 @@ Provider-specific props narrow the callback payload: ### 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`. +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 Date: Wed, 16 Sep 2026 15:25:20 +0200 Subject: [PATCH 10/11] fix: document sheet callout fallback and keep provider updater pure --- README.md | 2 +- ...05-apple-native-poi-detail-presentation.md | 4 +++- example/App.tsx | 22 +++++++++---------- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 9937c7e..2ce69c7 100644 --- a/README.md +++ b/README.md @@ -328,7 +328,7 @@ Apple MapKit can present its own place details for a selected point of interest | -------------- | ----------------------------------------------------- | | `'automatic'` | MapKit picks the presentation for the current context | | `'callout'` | Callout anchored to the selected place | -| `'sheet'` | Sheet presented from the map's view controller | +| `'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. diff --git a/docs/adr/0005-apple-native-poi-detail-presentation.md b/docs/adr/0005-apple-native-poi-detail-presentation.md index d7e8baf..095e2d3 100644 --- a/docs/adr/0005-apple-native-poi-detail-presentation.md +++ b/docs/adr/0005-apple-native-poi-detail-presentation.md @@ -49,6 +49,8 @@ at runtime. 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`. + 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 f472db8..ba8cb5c 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -770,18 +770,18 @@ export default function App() { }, [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) => { From e17a8e1b1fe19f7ad9f86c4497a3eb4b8d4f9584 Mon Sep 17 00:00:00 2001 From: Jakub Kasprzyk Date: Wed, 16 Sep 2026 15:48:11 +0200 Subject: [PATCH 11/11] fix(ios): gate presentation-only POI selection to iOS 18+ --- package/ios/AppleMapProviderAdapter.swift | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/package/ios/AppleMapProviderAdapter.swift b/package/ios/AppleMapProviderAdapter.swift index b59c5fe..b466f60 100644 --- a/package/ios/AppleMapProviderAdapter.swift +++ b/package/ios/AppleMapProviderAdapter.swift @@ -163,8 +163,9 @@ final class AppleMapProviderAdapter: MapProviderAdapter { } } - /// Native MapKit detail presentation for selected POIs (iOS 18+). Enables selectable - /// points of interest on its own, independently of `onPoiPress`. + /// 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) @@ -492,10 +493,21 @@ final class AppleMapProviderAdapter: MapProviderAdapter { } private func applySelectablePoiFeatures(to mapView: MKMapView) { - if #available(iOS 16.0, *) { - let wantsSelectablePois = onPoiPress != nil || applePoiDetailPresentation != nil - mapView.selectableMapFeatures = wantsSelectablePois ? .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 : [] } }