Overview

MapKit for SwiftUI (iOS 17+) replaces the UIViewRepresentable wrapper era. The new Map view, Annotation, Marker, MapPolyline, and MapCameraPosition give a declarative API that fits the rest of SwiftUI. This page covers the patterns to default to, the controls worth wiring up first, and the performance traps to avoid. For persistence of map data, see core-data; for the Swift language rules, see swift.

Use the SwiftUI Map view, not UIViewRepresentable

On iOS 17 and above, use the native Map initializer. Drop legacy MKMapView wrappers from new code; reach for them only when an API the SwiftUI surface does not expose is load-bearing (custom tile overlays, fine-grained gesture state).

import MapKit
import SwiftUI
 
struct CityMap: View {
  @State private var camera: MapCameraPosition = .region(
    MKCoordinateRegion(
      center: .init(latitude: 37.7749, longitude: -122.4194),
      span: .init(latitudeDelta: 0.05, longitudeDelta: 0.05)
    )
  )
  var body: some View {
    Map(position: $camera) {
      Marker("Ferry Building", coordinate: .init(latitude: 37.7955, longitude: -122.3937))
    }
  }
}

The new API ships with Marker, Annotation, polylines, polygons, look-around, and selection out of the box.

Pick Marker for stock pins, Annotation for custom views

Marker renders the system pin with a tint and a glyph. Use it when the visual is “a pin on a map.”

Marker("Office", systemImage: "building.2.fill", coordinate: office)
  .tint(.blue)

Annotation takes an arbitrary SwiftUI view. Use it for branded markers, photo bubbles, or anything with state.

Annotation("Trailhead", coordinate: trailhead) {
  Image(systemName: "figure.hiking")
    .padding(8)
    .background(.thinMaterial, in: .circle)
}

Default to Marker for density and Annotation only when you need the custom view; system pins draw faster.

Draw paths with MapPolyline and MapPolygon

Use the declarative overlays. They batch through MapKit’s renderer without a custom MKOverlayRenderer.

Map {
  MapPolyline(coordinates: route.coordinates)
    .stroke(.blue, lineWidth: 4)
  MapPolygon(coordinates: zone.coordinates)
    .foregroundStyle(.green.opacity(0.2))
    .stroke(.green, lineWidth: 2)
}

Keep the coordinate arrays in the model, not in the view; recomputing them in body re-uploads geometry on every render.

Drive the camera through MapCameraPosition

Use a single @State of type MapCameraPosition as the source of truth. Animate transitions with withAnimation.

@State private var camera: MapCameraPosition = .automatic
 
Button("Frame route") {
  withAnimation(.easeInOut) {
    camera = .rect(route.boundingRect, edgePadding: .init(top: 40, left: 40, bottom: 40, right: 40))
  }
}

Use .onMapCameraChange(frequency: .onEnd) to read the user’s final camera; the .continuous frequency fires on every frame and will dominate a profile.

Enable look-around and selection where they help

Look-around adds a street-level preview without leaving the app. Use LookAroundPreview bound to a scene fetched with MKLookAroundSceneRequest.

@State private var scene: MKLookAroundScene?
 
LookAroundPreview(scene: $scene, allowsNavigation: true)
  .frame(height: 180)
  .task(id: selectedCoordinate) {
    scene = try? await MKLookAroundSceneRequest(coordinate: selectedCoordinate).scene
  }

For selectable annotations, bind Map(selection:) to an Identifiable value and switch the detail view on the selection.

Cluster dense annotations and throttle camera work

A few hundred markers on screen will drop frames. Two rules cover most cases.

  • Cluster when annotation count exceeds the visible region’s complexity. Provide a clusterAnnotation view for Annotation items, or fall back to MKMapView clustering for the heavy cases.
  • Throttle camera-driven fetches. Use .onMapCameraChange(frequency: .onEnd) and debounce server calls; do not fire a request on every frame of a pan.

Render only what is inside the visible region. Filter your annotation list against context.region before passing it to Map.

Request location when in use; degrade gracefully

Request whenInUse authorization from CLLocationManager. Show the user’s location only after authorization resolves; never prompt at app launch.

@State private var manager = CLLocationManager()
 
.task {
  manager.requestWhenInUseAuthorization()
}
 
Map(position: $camera) {
  UserAnnotation()
}
.mapControls {
  MapUserLocationButton()
  MapCompass()
}

If the user denies access, hide the location button and center on a sensible default. Add NSLocationWhenInUseUsageDescription to Info.plist with a one-sentence reason; vague reasons get rejected at App Review.