```html SwiftUI: How to Build a Map View (iOS 17+, 2026)

How to Build a Map View in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: Map, MapContentBuilder, MapCameraPosition, Marker, Annotation Updated: May 11, 2026
TL;DR

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

  1. MapCameraPosition state@State private var position: MapCameraPosition is 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.
  2. MapContentBuilder closure — The trailing closure passed to Map(position:selection:) accepts a @MapContentBuilder result builder. Items like Annotation, Marker, and UserAnnotation are composable inside it, just like subviews in a VStack.
  3. Selection binding — Passing selection: $selectedPlace and tagging each Annotation with .tag(place) lets MapKit set selectedPlace automatically when the user taps a pin — no gesture recognizers needed.
  4. Map style modifier.mapStyle(.standard(elevation: .realistic)) applied to the Map view switches the tile rendering. Because resolvedMapStyle is computed from @State var mapStyle, SwiftUI re-renders the modifier reactively whenever the picker changes.
  5. mapControls — The .mapControls { } modifier (iOS 17+) declaratively places system-provided MapUserLocationButton, MapCompass, and MapScaleView overlays at their canonical positions, with no manual ZStack required.

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

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.

```