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', +};