Overview
MapCameraPosition is the single value that drives where the map looks in MapKit for SwiftUI (iOS 17+). Animating it moves the map; reading it after a user gesture gives the current viewport. Getting camera control right prevents the two most common map UX bugs: views that do not respond to user pans after a programmatic move, and fetch loops that fire on every animation frame. For annotation placement relative to the camera, see mapkit-annotations; for overlays that respond to zoom, see mapkit-overlays.
Use a single @State<MapCameraPosition> as the camera source of truth
Own one MapCameraPosition state per Map view. Do not keep a parallel MKCoordinateRegion or MKMapCamera; derive what you need from the MapCameraPosition binding.
@State private var camera: MapCameraPosition = .region(
MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: 37.3349, longitude: -122.0090),
span: MKCoordinateSpan(latitudeDelta: 0.1, longitudeDelta: 0.1)
)
)
var body: some View {
Map(position: $camera)
}Assigning a new value to camera from code moves the map; the user’s pan gestures also update camera through the binding. The two-way flow means you always know where the map is.
Animate camera moves with withAnimation
Wrap any camera assignment in withAnimation to produce a smooth fly-to transition. The default animation is adequate for most cases; use a spring for interactive, elastic feel.
Button("Go to Berlin") {
withAnimation(.easeInOut(duration: 0.6)) {
camera = .region(
MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: 52.5200, longitude: 13.4050),
span: MKCoordinateSpan(latitudeDelta: 0.08, longitudeDelta: 0.08)
)
)
}
}Avoid .automatic as the target when you want a predictable zoom level; .automatic lets MapKit choose the span and the result varies by device screen size.
Frame a bounding rect around a set of coordinates
When showing a route or a filtered result set, compute the bounding rect and apply padding so pins are not clipped by the safe area.
func frame(coordinates: [CLLocationCoordinate2D]) -> MapCameraPosition {
var rect = MKMapRect.null
for coord in coordinates {
let point = MKMapPoint(coord)
rect = rect.union(MKMapRect(x: point.x, y: point.y, width: 0, height: 0))
}
return .rect(rect, edgePadding: EdgeInsets(top: 60, leading: 40, bottom: 60, trailing: 40))
}Call this function in response to a search result or a route completion, then assign to camera inside withAnimation.
Enable follow-user mode with .userLocation
.userLocation(followsHeading: Bool, fallback: MapCameraPosition) centres the camera on the user and optionally rotates with device heading. Pass a fallback position for when location is unavailable or denied.
camera = .userLocation(followsHeading: true, fallback: .automatic)Request whenInUse authorisation before entering follow mode. Entering follow mode when location is denied leaves the camera at .automatic. Break follow mode explicitly when the user pans: read .onMapCameraChange and compare the new position to .userLocation.
Read camera state with .onEnd frequency, not .continuous
.onMapCameraChange(frequency: .continuous) fires on every animation frame during a pan or zoom. Use it only when you must update something visual in real time (a compass overlay, a scale bar). For triggering data fetches, use .onEnd.
.onMapCameraChange(frequency: .onEnd) { context in
viewModel.updateVisible(for: context.region)
}.onEnd fires once when the camera settles. Debounce even this callback if the fetch is a network request; a fast sequence of programmatic moves produces a sequence of .onEnd events.
Use .camera position for pitch and heading control
MapCameraPosition.camera(_:) gives access to MKMapCamera, which exposes pitch (3D tilt) and heading (rotation).
let camera3D = MKMapCamera(
lookingAtCenter: coordinate,
fromDistance: 1_000,
pitch: 45,
heading: 90
)
withAnimation {
camera = .camera(camera3D)
}Pitch above zero unlocks the 3D building layer. Avoid high-pitch views on low-end devices; the building geometry is expensive to render.
Restore camera state across app launches
Serialize the last-seen region to UserDefaults or core-data and restore it on launch. Persisting latitude, longitude, and span is sufficient for most apps; pitch and heading are secondary.
// On change
.onMapCameraChange(frequency: .onEnd) { context in
let region = context.region
UserDefaults.standard.set(region.center.latitude, forKey: "map.lat")
UserDefaults.standard.set(region.center.longitude, forKey: "map.lon")
UserDefaults.standard.set(region.span.latitudeDelta, forKey: "map.latDelta")
}
// On launch
func savedCamera() -> MapCameraPosition {
let lat = UserDefaults.standard.double(forKey: "map.lat")
let lon = UserDefaults.standard.double(forKey: "map.lon")
let delta = UserDefaults.standard.double(forKey: "map.latDelta")
guard lat != 0, lon != 0, delta != 0 else { return .automatic }
return .region(MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: lat, longitude: lon),
span: MKCoordinateSpan(latitudeDelta: delta, longitudeDelta: delta)
))
}