How to Build a Map View in SwiftUI
Use SwiftUI's Map view (iOS 17+) with a
MapContentBuilder closure to render an interactive map.
Bind a MapCameraPosition state variable to control the viewport, and drop
Marker or Annotation items inside the closure.
import MapKit
import SwiftUI
struct QuickMapView: View {
@State private var position: MapCameraPosition =
.region(MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194),
span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05)
))
var body: some View {
Map(position: $position) {
Marker("San Francisco", coordinate:
CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194))
}
.mapStyle(.standard)
}
}
Full implementation
The example below builds a location explorer screen. It holds a list of PlaceItem structs,
renders each as a tappable Annotation with a custom callout, and lets the user
switch between standard, satellite, and hybrid map styles via a Picker. A
selectedPlace binding drives an info sheet at the bottom of the screen.
import MapKit
import SwiftUI
// MARK: - Model
struct PlaceItem: Identifiable {
let id = UUID()
let name: String
let subtitle: String
let coordinate: CLLocationCoordinate2D
var systemImage: String = "mappin.circle.fill"
}
// MARK: - Map Style Option
enum MapStyleOption: String, CaseIterable, Identifiable {
case standard = "Standard"
case imagery = "Satellite"
case hybrid = "Hybrid"
var id: String { rawValue }
}
// MARK: - Main View
struct PlaceMapView: View {
let places: [PlaceItem] = [
PlaceItem(name: "Golden Gate Bridge",
subtitle: "Iconic suspension bridge",
coordinate: .init(latitude: 37.8199, longitude: -122.4783),
systemImage: "building.columns.fill"),
PlaceItem(name: "Alcatraz Island",
subtitle: "Historic federal penitentiary",
coordinate: .init(latitude: 37.8267, longitude: -122.4230),
systemImage: "lock.fill"),
PlaceItem(name: "Fisherman's Wharf",
subtitle: "Waterfront neighbourhood",
coordinate: .init(latitude: 37.8080, longitude: -122.4177),
systemImage: "fish.fill"),
]
@State private var position: MapCameraPosition = .region(
MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: 37.8150, longitude: -122.4390),
span: MKCoordinateSpan(latitudeDelta: 0.09, longitudeDelta: 0.09)
)
)
@State private var selectedPlace: PlaceItem?
@State private var mapStyle: MapStyleOption = .standard
var body: some View {
NavigationStack {
ZStack(alignment: .bottom) {
mapView
if let place = selectedPlace {
placeCard(for: place)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.navigationTitle("Explore SF")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
stylePicker
}
}
.animation(.spring(response: 0.35), value: selectedPlace?.id)
}
}
// MARK: - Subviews
private var mapView: some View {
Map(position: $position, selection: $selectedPlace) {
ForEach(places) { place in
Annotation(
place.name,
coordinate: place.coordinate,
anchor: .bottom
) {
annotationPin(for: place)
}
.tag(place)
.annotationTitles(.hidden)
.accessibilityLabel(place.name)
.accessibilityHint(place.subtitle)
}
UserAnnotation()
}
.mapStyle(resolvedMapStyle)
.mapControls {
MapUserLocationButton()
MapCompass()
MapScaleView()
}
.ignoresSafeArea(edges: .top)
}
private func annotationPin(for place: PlaceItem) -> some View {
ZStack {
Circle()
.fill(selectedPlace?.id == place.id ? Color.blue : Color.white)
.frame(width: 44, height: 44)
.shadow(radius: 4)
Image(systemName: place.systemImage)
.foregroundStyle(selectedPlace?.id == place.id ? .white : .blue)
.font(.system(size: 20))
}
}
private func placeCard(for place: PlaceItem) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Image(systemName: place.systemImage)
.foregroundStyle(.blue)
Text(place.name).font(.headline)
Spacer()
Button {
selectedPlace = nil
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
.accessibilityLabel("Dismiss")
}
Text(place.subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
Button("Get Directions") {
openInMaps(place)
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
}
.padding()
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
.padding(.horizontal)
.padding(.bottom, 24)
}
private var stylePicker: some View {
Picker("Map Style", selection: $mapStyle) {
ForEach(MapStyleOption.allCases) { option in
Text(option.rawValue).tag(option)
}
}
.pickerStyle(.menu)
.accessibilityLabel("Map style selector")
}
// MARK: - Helpers
private var resolvedMapStyle: MapStyle {
switch mapStyle {
case .standard: return .standard(elevation: .realistic)
case .imagery: return .imagery
case .hybrid: return .hybrid
}
}
private func openInMaps(_ place: PlaceItem) {
let item = MKMapItem(placemark: MKPlacemark(coordinate: place.coordinate))
item.name = place.name
item.openInMaps()
}
}
// MARK: - Preview
#Preview {
PlaceMapView()
}
How it works
-
MapCameraPosition state —
@State private var position: MapCameraPositionis initialised with.region(_:)to set the starting viewport. Because it's bound with$position, the map writes back to it as the user pans and zooms, letting you read or programmatically move the camera at any time. -
MapContentBuilder closure — The trailing closure passed to
Map(position:selection:)accepts a@MapContentBuilderresult builder. Items likeAnnotation,Marker, andUserAnnotationare composable inside it, just like subviews in aVStack. -
Selection binding — Passing
selection: $selectedPlaceand tagging eachAnnotationwith.tag(place)lets MapKit setselectedPlaceautomatically when the user taps a pin — no gesture recognizers needed. -
Map style modifier —
.mapStyle(.standard(elevation: .realistic))applied to theMapview switches the tile rendering. BecauseresolvedMapStyleis computed from@State var mapStyle, SwiftUI re-renders the modifier reactively whenever the picker changes. -
mapControls — The
.mapControls { }modifier (iOS 17+) declaratively places system-providedMapUserLocationButton,MapCompass, andMapScaleViewoverlays at their canonical positions, with no manualZStackrequired.
Variants
Clustering markers from a large dataset
For tens or hundreds of pins, wrap each Marker in a
ForEach and add
.annotationTitles(.automatic) — MapKit handles visual
clustering at lower zoom levels automatically on iOS 17+.
struct ClusteredMapView: View {
let hotspots: [PlaceItem] // large array
@State private var position: MapCameraPosition = .automatic
var body: some View {
Map(position: $position) {
ForEach(hotspots) { spot in
Marker(spot.name, systemImage: spot.systemImage,
coordinate: spot.coordinate)
.tint(.orange)
// MapKit auto-clusters at low zoom when titles are visible
.annotationTitles(.automatic)
}
}
.mapStyle(.standard)
.onAppear {
// .automatic fits all annotations in view
position = .automatic
}
}
}
Drawing a polyline route
Add a MapPolyline inside the
MapContentBuilder closure and style it with
.stroke(.blue, lineWidth: 4).
Pass an array of CLLocationCoordinate2D values
obtained from MKDirections or a
hardcoded GPX track. Example:
MapPolyline(coordinates: routeCoordinates).stroke(.blue, lineWidth: 4)
— no MKOverlayRenderer subclass required.
Common pitfalls
-
⚠️ iOS 17 API break: The pre-iOS 17
Map(coordinateRegion:)initializer is deprecated. The new closure-basedMap(position:) { }API is the only forward-compatible path. If you still need iOS 16 support you must conditionally branch with#if swift(>=5.9)or raise your deployment target. -
⚠️ Missing NSLocationWhenInUseUsageDescription: Calling
UserAnnotation()or requesting the user's location without adding the privacy key to Info.plist will crash silently on device and reject during App Store review. Always add the key even before you wire upCLLocationManager. -
⚠️ Performance with many custom Annotation views: SwiftUI
Annotationrenders arbitrary views, but each one is a full SwiftUI render pass. For more than ~50 pins, prefer the lightweightMarker(system-rendered) instead, or implement viewport-based filtering so only on-screen pins enter the builder.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement a map view in SwiftUI for iOS 17+. Use Map, MapContentBuilder, MapCameraPosition, Marker, and Annotation. Display a list of PlaceItem structs as custom annotation pins. Support standard, satellite, and hybrid map styles via a toolbar Picker. Show a bottom card when a pin is selected. Make it accessible (VoiceOver labels, accessibilityHint for each pin). Add a #Preview with realistic sample data for San Francisco landmarks.
In Soarias, paste this during the Build phase — the implementation agent will generate the full view and wire it to any existing SwiftData models automatically.
Related
FAQ
Does this work on iOS 16? ▾
No — the Map(position:) initialiser and
MapContentBuilder are iOS 17+ only. On iOS 16 you must use the
deprecated Map(coordinateRegion:annotationItems:) initialiser.
If you need to support iOS 16, wrap both in an if #available(iOS 17, *)
branch, or simply raise your deployment target to iOS 17 (recommended for new projects in 2026).
How do I programmatically fly the camera to a new coordinate? ▾
Assign a new value to your @State var position: MapCameraPosition
inside any SwiftUI action:
position = .camera(MapCamera(centerCoordinate: newCoord, distance: 1000))
Use .region(_:) for a span-based zoom or
.camera(_:) for precise altitude and pitch control.
Wrap the assignment in withAnimation for a smooth fly-to effect.
What's the UIKit equivalent? ▾
The UIKit equivalent is MKMapView with an
MKMapViewDelegate. You would add annotations via
addAnnotations(_:) and return reusable
MKAnnotationView instances from the delegate.
The SwiftUI Map view wraps
MKMapView internally, so if you need a capability not yet
exposed by SwiftUI (e.g. MKTileOverlay), you can bridge via
UIViewRepresentable.
Last reviewed: 2026-05-11 by the Soarias team.