MapKit for SwiftUI: Maps, Markers, Routing, and Custom Styles


Drop a pin on a map. It sounds simple — until you try it with the pre-iOS 17 Map view and realize you are fighting MKCoordinateRegion bindings, wrapping MKMapView in UIViewRepresentable, and manually converting annotation models. iOS 17 rewrote the MapKit SwiftUI API from scratch, and the result is the most expressive map API Apple has ever shipped.

This guide covers the new Map initializer, Marker and Annotation content builders, MapCameraPosition for programmatic camera control, MapStyle for visual customization, MapPolyline for drawing routes, and integrating MKDirections for turn-by-turn routing. We will not cover MKLocalSearch or the Look Around API — those deserve their own posts.

Contents

The Problem

Before iOS 17, placing custom content on a SwiftUI map required either the limited Map(coordinateRegion:annotationItems:) initializer or dropping down to UIViewRepresentable:

// ❌ Pre-iOS 17: limited annotation support, no polylines, no camera control
struct OldPixarStudioMap: View {
    @State private var region = MKCoordinateRegion(
        center: CLLocationCoordinate2D(latitude: 37.8323, longitude: -122.2864),
        span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05)
    )

    let studios: [PixarStudio]

    var body: some View {
        Map(coordinateRegion: $region, annotationItems: studios) { studio in
            MapAnnotation(coordinate: studio.coordinate) {
                // Custom view — but no system tint, no clustering, no selection
                Image(systemName: "film")
                    .foregroundColor(.blue)
            }
        }
        // No way to draw a route polyline
        // No way to animate to a camera position
        // No way to change the map style
    }
}

This API could not draw polylines, did not support programmatic camera animation, had no map style options, and its two-way MKCoordinateRegion binding was the source of well-documented SwiftUI state loop bugs. iOS 17 replaced it entirely.

The New Map View

The iOS 17 Map view uses a content builder — the same @MapContentBuilder pattern you know from SwiftUI’s @ViewBuilder. Instead of passing an annotation array, you declare map content declaratively:

import MapKit
import SwiftUI

@available(iOS 17.0, *)
struct PixarStudioMap: View {
    var body: some View {
        Map {
            Marker(
                "Pixar Animation Studios",
                coordinate: CLLocationCoordinate2D(
                    latitude: 37.8323,
                    longitude: -122.2864
                )
            )
            .tint(.blue)
        }
    }
}

That is a fully functional map with a tinted marker — no bindings, no region management, no boilerplate. The Map view manages its own camera state by default, starting centered on the content you provide.

Markers and Annotations

MapKit for SwiftUI distinguishes between two content types: Marker for system-styled pins and Annotation for fully custom SwiftUI views.

Markers

Markers are the standard balloon pins. They support a title, system image, monogram, and tint color:

@available(iOS 17.0, *)
struct PixarLocationsMap: View {
    let locations: [PixarLocation]

    var body: some View {
        Map {
            ForEach(locations) { location in
                Marker(
                    location.name,
                    systemImage: location.iconName,
                    coordinate: location.coordinate
                )
                .tint(location.studioColor)
            }
        }
    }
}

struct PixarLocation: Identifiable {
    let id = UUID()
    let name: String
    let iconName: String
    let coordinate: CLLocationCoordinate2D
    let studioColor: Color
}

You can use Marker(_:monogram:coordinate:) to show a two-letter abbreviation instead of an icon — useful when you have more locations than distinct SF Symbols.

Annotations

When a standard marker is not enough, Annotation lets you provide an arbitrary SwiftUI view anchored to a coordinate:

@available(iOS 17.0, *)
struct ToyStoryLocationMap: View {
    var body: some View {
        Map {
            Annotation(
                "Andy's House",
                coordinate: CLLocationCoordinate2D(
                    latitude: 34.0522, longitude: -118.2437
                )
            ) {
                VStack(spacing: 4) {
                    Image(systemName: "house.fill")
                        .font(.title2)
                        .foregroundStyle(.white)
                        .padding(8)
                        .background(.orange, in: Circle())
                    Text("Andy's House")
                        .font(.caption2)
                        .bold()
                }
            }

            Annotation(
                "Pizza Planet",
                coordinate: CLLocationCoordinate2D(
                    latitude: 34.0195, longitude: -118.4912
                )
            ) {
                VStack(spacing: 4) {
                    Image(systemName: "fork.knife")
                        .font(.title2)
                        .foregroundStyle(.white)
                        .padding(8)
                        .background(.green, in: Circle())
                    Text("Pizza Planet")
                        .font(.caption2)
                        .bold()
                }
            }
        }
    }
}

Tip: Annotations are full SwiftUI views, which means they participate in animation, accessibility, and dynamic type. But they are more expensive to render than Markers. Use Markers for large datasets (hundreds of pins) and Annotations only where custom visuals are essential.

Selection

The new Map supports built-in selection. Bind a Marker or Annotation tag to a selection state:

@available(iOS 17.0, *)
struct SelectableStudioMap: View {
    let studios: [PixarLocation]
    @State private var selectedStudio: PixarLocation.ID?

    var body: some View {
        Map(selection: $selectedStudio) {
            ForEach(studios) { studio in
                Marker(studio.name, coordinate: studio.coordinate)
                    .tag(studio.id)
            }
        }
        .onChange(of: selectedStudio) { _, newValue in
            if let selected = studios.first(where: { $0.id == newValue }) {
                print("Selected: \(selected.name)")
            }
        }
    }
}

When the user taps a marker, selectedStudio updates automatically. No delegate callbacks, no gesture recognizers.

Camera Control with MapCameraPosition

MapCameraPosition replaces the old MKCoordinateRegion binding. It is an enum with cases for common positioning strategies:

@available(iOS 17.0, *)
struct CameraControlDemo: View {
    @State private var cameraPosition: MapCameraPosition = .automatic

    var body: some View {
        VStack {
            Map(position: $cameraPosition) {
                Marker("Pixar HQ", coordinate: pixarHQ)
                Marker("Walt Disney Studios", coordinate: disneyStudios)
            }

            HStack {
                Button("Pixar") {
                    withAnimation(.easeInOut(duration: 1.0)) {
                        cameraPosition = .region(MKCoordinateRegion(
                            center: pixarHQ,
                            latitudinalMeters: 1000,
                            longitudinalMeters: 1000
                        ))
                    }
                }

                Button("Disney") {
                    withAnimation(.easeInOut(duration: 1.0)) {
                        cameraPosition = .camera(MapCamera(
                            centerCoordinate: disneyStudios,
                            distance: 2000,
                            heading: 45,
                            pitch: 60
                        ))
                    }
                }

                Button("Show All") {
                    withAnimation {
                        cameraPosition = .automatic
                    }
                }
            }
            .buttonStyle(.bordered)
        }
    }

    private let pixarHQ = CLLocationCoordinate2D(
        latitude: 37.8323, longitude: -122.2864
    )
    private let disneyStudios = CLLocationCoordinate2D(
        latitude: 34.1561, longitude: -118.3254
    )
}

The key MapCameraPosition cases:

  • .automatic — Fits all map content in the visible area. The default.
  • .region(MKCoordinateRegion) — Centers on a specific region with a span.
  • .camera(MapCamera) — Full control over center, distance, heading, and pitch for 3D perspectives.
  • .userLocation(fallback:) — Tracks the user’s location with a fallback position.
  • .item(MKMapItem) — Centers on a specific map item.

Note: Camera animations use standard SwiftUI withAnimation — wrap the cameraPosition assignment in an animation block and MapKit handles the flyover transition.

Reacting to Camera Changes

Use the onMapCameraChange modifier to observe when the user pans, zooms, or rotates the map:

@available(iOS 17.0, *)
struct ObservableCameraMap: View {
    @State private var cameraPosition: MapCameraPosition = .automatic
    @State private var visibleRegion: MKCoordinateRegion?

    var body: some View {
        Map(position: $cameraPosition) {
            ForEach(studios) { studio in
                Marker(studio.name, coordinate: studio.coordinate)
            }
        }
        .onMapCameraChange(frequency: .onEnd) { context in
            visibleRegion = context.region
            // Use visibleRegion to fetch nearby POIs or filter data
        }
    }
}

The frequency parameter can be .onEnd (fires once after the camera settles) or .continuous (fires during gesture — use sparingly as it impacts performance).

Map Styles

MapStyle controls the visual appearance of the map. Apply it with the .mapStyle(_:) modifier:

@available(iOS 17.0, *)
struct StyledPixarMap: View {
    @State private var selectedStyle: MapStyleOption = .standard

    var body: some View {
        Map {
            Marker("Pixar HQ", coordinate: pixarHQ)
        }
        .mapStyle(selectedStyle.style)
    }

    private let pixarHQ = CLLocationCoordinate2D(
        latitude: 37.8323, longitude: -122.2864
    )
}

enum MapStyleOption: String, CaseIterable {
    case standard, hybrid, imagery

    var style: MapStyle {
        switch self {
        case .standard:
            return .standard(
                elevation: .realistic,
                pointsOfInterest: .including([.restaurant, .movieTheater])
            )
        case .hybrid:
            return .hybrid(elevation: .realistic)
        case .imagery:
            return .imagery(elevation: .realistic)
        }
    }
}

The .standard style accepts a pointsOfInterest parameter that filters which POI categories appear on the map. For a Pixar location finder, you might include only .movieTheater, .museum, and .amusementPark to keep the map focused.

Note: MapStyle.imagery (satellite view) does not support points-of-interest overlays. Switching to imagery silently drops any POI configuration.

Drawing Routes with MapPolyline

MapPolyline draws a path on the map. You provide an array of coordinates or an MKRoute:

@available(iOS 17.0, *)
struct PixarStudioTourRoute: View {
    let tourStops: [CLLocationCoordinate2D]

    var body: some View {
        Map {
            // Draw the route
            MapPolyline(coordinates: tourStops)
                .stroke(.blue, lineWidth: 4)

            // Mark each stop
            ForEach(Array(tourStops.enumerated()), id: \.offset) { index, coordinate in
                Marker("Stop \(index + 1)", coordinate: coordinate)
                    .tint(.orange)
            }
        }
    }
}

MapPolyline also accepts an MKRoute directly, which is the natural output of MKDirections — see the next section.

Routing with MKDirections

To draw a real driving or walking route between two points, combine MKDirections with MapPolyline. Here is a complete example that calculates a route between two Pixar-themed landmarks:

import MapKit
import SwiftUI

@available(iOS 17.0, *)
struct PixarRoadTripView: View {
    @State private var route: MKRoute?
    @State private var cameraPosition: MapCameraPosition = .automatic
    @State private var travelTime: String?
    @State private var isCalculating = false

    private let origin = CLLocationCoordinate2D(
        latitude: 37.8323, longitude: -122.2864
    )
    private let destination = CLLocationCoordinate2D(
        latitude: 34.1561, longitude: -118.3254
    )

    var body: some View {
        VStack(spacing: 0) {
            Map(position: $cameraPosition) {
                Marker("Pixar HQ", systemImage: "building.2", coordinate: origin)
                    .tint(.blue)

                Marker("Disney Studios", systemImage: "sparkles", coordinate: destination)
                    .tint(.purple)

                if let route {
                    MapPolyline(route.polyline)
                        .stroke(.blue, lineWidth: 5)
                }
            }
            .mapStyle(.standard(elevation: .realistic))

            if let travelTime {
                Text("Estimated travel time: \(travelTime)")
                    .font(.subheadline)
                    .padding()
                    .frame(maxWidth: .infinity)
                    .background(.ultraThinMaterial)
            }
        }
        .overlay(alignment: .topTrailing) {
            if isCalculating {
                ProgressView()
                    .padding()
                    .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 8))
                    .padding()
            }
        }
        .task { await calculateRoute() }
    }

    private func calculateRoute() async {
        isCalculating = true
        defer { isCalculating = false }

        let request = MKDirections.Request()
        request.source = MKMapItem(placemark: MKPlacemark(coordinate: origin))
        request.destination = MKMapItem(
            placemark: MKPlacemark(coordinate: destination)
        )
        request.transportType = .automobile

        do {
            let directions = MKDirections(request: request)
            let response = try await directions.calculate()

            if let primaryRoute = response.routes.first {
                route = primaryRoute

                let formatter = DateComponentsFormatter()
                formatter.unitsStyle = .abbreviated
                formatter.allowedUnits = [.hour, .minute]
                travelTime = formatter.string(
                    from: primaryRoute.expectedTravelTime
                )

                withAnimation(.easeInOut(duration: 1.5)) {
                    cameraPosition = .automatic
                }
            }
        } catch {
            print("Route calculation failed: \(error.localizedDescription)")
        }
    }
}

MKDirections.calculate() is async as of iOS 16 — no completion handler required. The response contains an array of MKRoute objects, each with a polyline property that MapPolyline accepts directly.

Warning: MKDirections requests count against your Apple Maps Server API quota. In production, cache routes when the origin and destination have not changed, and debounce recalculations when the user adjusts a draggable pin.

Advanced Usage

Map Interaction Modes

You can restrict how users interact with the map using the .mapControls modifier and MapInteractionModes:

@available(iOS 17.0, *)
struct RestrictedInteractionMap: View {
    var body: some View {
        Map(interactionModes: [.pan, .zoom]) {
            // Pitch and rotate disabled — useful for 2D-only map UIs
            Marker("Monsters, Inc.", coordinate: monstersIncHQ)
        }
        .mapControls {
            MapCompass()
            MapScaleView()
            MapUserLocationButton()
        }
    }

    private let monstersIncHQ = CLLocationCoordinate2D(
        latitude: 37.7749, longitude: -122.4194
    )
}

The .mapControls builder lets you choose which system controls appear. By default, all controls are shown. Passing an empty builder hides them all — useful for decorative maps or backgrounds.

User Location Tracking

To display and track the user’s location, use MapCameraPosition.userLocation(fallback:) combined with UserAnnotation:

@available(iOS 17.0, *)
struct UserTrackingMap: View {
    @State private var cameraPosition: MapCameraPosition = .userLocation(
        fallback: .automatic
    )

    var body: some View {
        Map(position: $cameraPosition) {
            UserAnnotation()

            // Other content
            Marker("Nemo's Reef", coordinate: nemoReef)
        }
        .mapControls {
            MapUserLocationButton()
            MapCompass()
        }
    }

    private let nemoReef = CLLocationCoordinate2D(
        latitude: -16.4827, longitude: 145.4631
    )
}

Note: User location requires the NSLocationWhenInUseUsageDescription key in your Info.plist and a corresponding CLLocationManager authorization request. The map will not show the user dot without location permission.

MapReader for Coordinate Conversion

MapReader provides a MapProxy that converts between screen points and map coordinates — essential for “tap to place a pin” features:

@available(iOS 17.0, *)
struct TapToPlacePinMap: View {
    @State private var pins: [CLLocationCoordinate2D] = []

    var body: some View {
        MapReader { proxy in
            Map {
                ForEach(Array(pins.enumerated()), id: \.offset) { index, coordinate in
                    Marker("Pin \(index + 1)", coordinate: coordinate)
                        .tint(.red)
                }
            }
            .onTapGesture { screenPoint in
                if let coordinate = proxy.convert(screenPoint, from: .local) {
                    pins.append(coordinate)
                }
            }
        }
    }
}

Performance Considerations

MapKit’s SwiftUI layer is backed by the same MKMapView rendering engine. The SwiftUI wrapper adds a thin overhead for diffing map content, but the dominant costs are tile loading and annotation rendering:

  • Marker vs. AnnotationMarker is GPU-rendered by the map engine. Annotation creates a UIKit host for each SwiftUI view. For datasets larger than 50 points, prefer Marker and switch to Annotation only for the selected item.
  • MapPolyline rendering — Polylines with thousands of coordinates render efficiently because MapKit simplifies the geometry at each zoom level. You do not need to pre-simplify coordinate arrays.
  • Camera animation — The .easeInOut animations on MapCameraPosition run on the render server, not the main thread. They perform well even during heavy view updates.
  • MKDirections API quota — Apple does not publish exact rate limits, but aggressive polling (recalculating routes on every map pan) will result in throttled responses. Cache routes and recalculate only on meaningful user actions.
  • Map content diffing — The @MapContentBuilder diffs content by identity. Ensure your ForEach data conforms to Identifiable with stable IDs. Using array indices as IDs causes the entire map content to reload on every data change.

Apple Docs: Map — MapKit for SwiftUI

When to Use (and When Not To)

ScenarioRecommendation
Interactive map with pins and user locationMap with Marker/Annotation — the primary use case
Turn-by-turn navigation UIMKDirections for route calculation, but consider CarPlay APIs for full navigation
Static decorative map (e.g., contact page)Map with restricted interactionModes and no controls
Hundreds of clustered data pointsUse Marker (not Annotation) and implement server-side clustering for 1000+ points
Map in a UIKit-based appMKMapView directly — wrapping SwiftUI Map in UIHostingController adds overhead
Fully custom map tiles or non-Apple base mapsMapbox GL or Google Maps SDK — MapKit does not support custom tile servers

Summary

  • iOS 17’s Map view uses a @MapContentBuilder for declarative, composable map content — Marker for system-styled pins, Annotation for custom SwiftUI views.
  • MapCameraPosition replaces the fragile MKCoordinateRegion binding with type-safe cases for .automatic, .region, .camera, .userLocation, and .item positioning, all animatable with standard SwiftUI animations.
  • MapStyle provides .standard, .hybrid, and .imagery styles with options for elevation, emphasis, and point-of-interest filtering.
  • MapPolyline draws routes and paths, accepting either raw coordinates or an MKRoute from MKDirections.calculate().
  • MapReader with MapProxy converts between screen points and map coordinates for tap-to-place-pin interactions.

For the location data that powers your map, see Core Location Modern API: Async Updates and Geofencing. To build a complete location-based app from scratch, check out Build a Maps App with MapKit.