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() }