Overview
MapKit for SwiftUI (iOS 17+) is declarative: a Map takes content built from Marker, Annotation, UserAnnotation, MapPolyline, MapPolygon, and MapCircle, and a MapCameraPosition binding drives the viewport. Use it instead of wrapping MKMapView. Camera control is in mapkit-camera and place search in mapkit-search; keep map data in a model layer backed by swiftdata or core-data.
Fall back to MKMapView only for what SwiftUI lacks
Wrap MKMapView in a UIViewRepresentable for native annotation clustering, MKTileOverlay raster layers, and custom MKOverlayRenderer subclasses. Everything else belongs in Map.
Map(position: $camera) {
Marker("Ferry Building", coordinate: ferry)
MapPolyline(coordinates: route.coordinates).stroke(.blue, lineWidth: 4)
}Use Marker by default and Annotation for custom views
Marker draws the system pin with a tint and optional SF Symbol glyph. Annotation hosts any SwiftUI view, such as a branded badge or a live count. Every Annotation is a live SwiftUI view, so prefer Marker when many items are on screen.
Marker("Office", systemImage: "building.2.fill", coordinate: office).tint(.blue)
Annotation("Trailhead", coordinate: trailhead) {
Image(systemName: "figure.hiking").padding(10).background(.thinMaterial, in: .circle)
}Give custom annotation views a hit area of at least 44 by 44 points (.padding plus .contentShape) and an .accessibilityLabel; a Marker exposes its title automatically.
Bind selection with tag
Map(selection:) takes an optional selection binding. Give each Marker or Annotation a .tag of the same Hashable type (also Identifiable for .sheet(item:)), and a mismatched type makes taps do nothing. With Marker(item:) for MKMapItem values, bind the selection to an MKMapItem? directly.
@State private var selected: Place?
Map(selection: $selected) {
ForEach(places) { Marker($0.name, coordinate: $0.coordinate).tag($0) }
}
.sheet(item: $selected) { PlaceDetail(place: $0) }Cluster by hand, and filter to the visible region
Map content had no declarative clustering API as of iOS 26; check the current SDK before building your own. Bucket coordinates into a grid sized from the current zoom, render one Annotation per cluster or single pin, and rebuild on .onMapCameraChange(frequency: .onEnd) so clusters split as the user zooms. Before building, drop items outside context.region. For tens of thousands of points or the system’s declustering animation, use MKMapView with clusteringIdentifier.
func clusters(_ places: [Place], cell: Double) -> [[Place]] {
Dictionary(grouping: places) {
"\(Int($0.coordinate.latitude / cell)):\(Int($0.coordinate.longitude / cell))"
}.map(\.value)
}Use .onEnd, never .continuous, for data work; continuous callbacks fire throughout a gesture.
Draw overlays declaratively
MapPolyline, MapPolygon, and MapCircle take .stroke and .foregroundStyle. Keep coordinate arrays in the model; recomputing a large array in body re-uploads geometry on every render. Simplify dense geometry (Ramer-Douglas-Peucker) once at import and store the result. The SwiftUI .mapOverlayLevel(level:) modifier sets z-order relative to roads and labels.
Map does not hand you overlay taps. Wrap it in MapReader, convert the tap with proxy.convert(_:from:), and test containment against your own geometry. Custom tile layers and renderers go through MKMapView: add an MKTileOverlay and return MKTileOverlayRenderer from mapView(_:rendererFor:), setting canReplaceMapContent only when the tiles are an opaque base map.
Show Look Around only when a scene exists
MKLookAroundSceneRequest(coordinate:) is async, returns nil when there is no imagery, and throws when the request fails, so branch on the result and show a plain fallback otherwise. Drive it from .task(id:) keyed on the place’s identifier (CLLocationCoordinate2D is not Equatable), so a different place cancels the previous fetch.
@State private var scene: MKLookAroundScene?
Group {
if let scene { LookAroundPreview(initialScene: scene).frame(height: 180) }
else { Label("No street-level imagery", systemImage: "eye.slash") }
}
.task(id: place.id) {
scene = try? await MKLookAroundSceneRequest(coordinate: place.coordinate).scene
}Set allowsNavigation: false in compact sheets. For list thumbnails use MKLookAroundSnapshotter and cache the image by coordinate, because each snapshot is a network call. Coverage varies by region, so never gate directions or open-in-Maps on it.
Request location when in use and degrade gracefully
Request whenInUse authorization from a CLLocationManager at the moment a feature needs it, not at launch, and put a specific reason in NSLocationWhenInUseUsageDescription. If access is denied, hide the location button and center on a sensible default.
Map(position: $camera) { UserAnnotation() }
.mapControls { MapUserLocationButton(); MapCompass() }