Overview

MapCameraPosition is the single value that says where a SwiftUI Map looks (iOS 17+). Assigning it from code moves the map, and the user’s gestures write back through the binding. Annotation and overlay content is in mapkit; search results that move the camera are in mapkit-search.

Keep one MapCameraPosition as the source of truth

Own one @State per Map and derive everything else from it. Do not keep a parallel MKCoordinateRegion.

@State private var camera: MapCameraPosition = .region(
  MKCoordinateRegion(
    center: .init(latitude: 37.3349, longitude: -122.0090),
    span: .init(latitudeDelta: 0.1, longitudeDelta: 0.1)))
 
Map(position: $camera)

The factory values are .automatic, .region(_:), .rect(_:), .item(_:) (frame an MKMapItem), .camera(_:), and .userLocation(followsHeading:fallback:). .automatic lets MapKit choose the span, so the zoom varies by screen size; use an explicit region when you need a predictable zoom.

Animate moves with withAnimation

Wrap the assignment to get a smooth fly-to.

withAnimation(.easeInOut(duration: 0.6)) {
  camera = .region(MKCoordinateRegion(
    center: .init(latitude: 52.52, longitude: 13.405),
    span: .init(latitudeDelta: 0.08, longitudeDelta: 0.08)))
}

Frame a set of coordinates

Union the points into an MKMapRect, then inflate it so pins are not clipped at the edges. .rect(_:) takes only the rectangle, so apply the padding to the rectangle itself.

func frame(_ coordinates: [CLLocationCoordinate2D]) -> MapCameraPosition {
  let rect = coordinates.reduce(MKMapRect.null) {
    $0.union(MKMapRect(origin: MKMapPoint($1), size: MKMapSize(width: 0, height: 0)))
  }
  return .rect(rect.insetBy(dx: -rect.width * 0.2, dy: -rect.height * 0.2))
}

Assign the result inside withAnimation after a search or route completes.

Follow the user with a fallback

.userLocation(followsHeading: true, fallback: .automatic) centers on the user and optionally rotates with the device heading. The fallback position applies when location is unavailable or denied, so request whenInUse authorization before entering follow mode (mapkit).

Set pitch and heading with MapCamera

.camera(_:) takes a MapCamera, which adds distance, heading (degrees from true north), and pitch (tilt).

camera = .camera(MapCamera(centerCoordinate: coordinate, distance: 1_000, heading: 90, pitch: 45))

Read camera changes with .onEnd

.onMapCameraChange(frequency: .continuous) fires throughout a pan or zoom; use it only for visuals that must track the map, such as a scale bar. For data fetches, filtering, or saving state use .onEnd, which fires once when the camera settles, and debounce network calls because programmatic moves also emit it. The callback’s context exposes the current region, rect, and camera.

.onMapCameraChange(frequency: .onEnd) { context in
  model.updateVisible(for: context.region)
}

Restore the last viewport on launch

Persist the center and span from the .onEnd context (a small Codable struct in UserDefaults is enough) and rebuild a .region at launch. Pitch and heading are secondary. Fall back to .automatic when nothing is stored, and store a flag rather than treating a 0.0 coordinate as “missing”.