```html SwiftUI: How to Build Route Planning (iOS 17+, 2026)

How to Build Route Planning in SwiftUI

iOS 17+ Xcode 16+ Advanced APIs: MKDirections Updated: May 12, 2026
TL;DR

Use MKDirections with an async/await call to calculate a route between two MKMapItem locations, then render the resulting MKPolyline on a SwiftUI Map using MapPolyline.

import MapKit
import SwiftUI

struct QuickRouteView: View {
    @State private var route: MKRoute?

    var body: some View {
        Map {
            if let route {
                MapPolyline(route.polyline)
                    .stroke(.blue, lineWidth: 4)
            }
        }
        .task { await calculateRoute() }
    }

    func calculateRoute() async {
        let request = MKDirections.Request()
        request.source = .forCurrentLocation()
        request.destination = MKMapItem(placemark:
            MKPlacemark(coordinate: CLLocationCoordinate2D(
                latitude: 37.7749, longitude: -122.4194)))
        request.transportType = .automobile
        let directions = MKDirections(request: request)
        let response = try? await directions.calculate()
        route = response?.routes.first
    }
}

Full implementation

The complete implementation combines an observable view model that manages the route calculation lifecycle with a SwiftUI Map that renders the polyline and step annotations. The view model exposes the calculated MKRoute — including individual step instructions — while a sheet displays turn-by-turn directions. An error state with retry capability handles failures such as no network connection or an unreachable destination.

import MapKit
import SwiftUI

// MARK: - ViewModel

@Observable
final class RouteViewModel {
    var route: MKRoute?
    var isLoading = false
    var errorMessage: String?
    var showSteps = false

    var steps: [MKRoute.Step] {
        route?.steps ?? []
    }

    var cameraBounds: MapCameraBounds? {
        guard let poly = route?.polyline else { return nil }
        let rect = poly.boundingMapRect
        return MapCameraBounds(
            centerCoordinateBounds: MKCoordinateRegion(rect),
            minimumDistance: nil,
            maximumDistance: nil
        )
    }

    func calculate(
        from source: MKMapItem,
        to destination: MKMapItem,
        transportType: MKDirectionsTransportType = .automobile
    ) async {
        isLoading = true
        errorMessage = nil
        route = nil
        defer { isLoading = false }

        let request = MKDirections.Request()
        request.source = source
        request.destination = destination
        request.transportType = transportType
        request.requestsAlternateRoutes = false

        do {
            let directions = MKDirections(request: request)
            let response = try await directions.calculate()
            route = response.routes.first
        } catch {
            errorMessage = error.localizedDescription
        }
    }
}

// MARK: - Main View

struct RoutePlanningView: View {
    @State private var vm = RouteViewModel()

    // San Francisco → Apple Park
    private let origin = MKMapItem(placemark: MKPlacemark(
        coordinate: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194)
    ))
    private let destination: MKMapItem = {
        let item = MKMapItem(placemark: MKPlacemark(
            coordinate: CLLocationCoordinate2D(latitude: 37.3349, longitude: -122.0090)
        ))
        item.name = "Apple Park"
        return item
    }()

    var body: some View {
        ZStack(alignment: .bottom) {
            Map(bounds: vm.cameraBounds) {
                // Origin marker
                Marker("Start", systemImage: "figure.walk",
                       coordinate: origin.placemark.coordinate)
                    .tint(.green)

                // Destination marker
                Marker(destination.name ?? "Destination",
                       systemImage: "mappin.circle.fill",
                       coordinate: destination.placemark.coordinate)
                    .tint(.red)

                // Route polyline
                if let route = vm.route {
                    MapPolyline(route.polyline)
                        .stroke(
                            .blue.gradient,
                            style: StrokeStyle(lineWidth: 5, lineCap: .round, lineJoin: .round)
                        )
                }
            }
            .mapStyle(.standard(elevation: .realistic))
            .mapControls {
                MapCompass()
                MapScaleView()
                MapUserLocationButton()
            }

            // Bottom card
            VStack(spacing: 0) {
                if vm.isLoading {
                    HStack(spacing: 10) {
                        ProgressView()
                        Text("Calculating route…")
                            .font(.subheadline)
                            .foregroundStyle(.secondary)
                    }
                    .padding()
                    .frame(maxWidth: .infinity)
                    .background(.ultraThinMaterial)
                    .clipShape(RoundedRectangle(cornerRadius: 16))
                    .padding()
                } else if let error = vm.errorMessage {
                    VStack(spacing: 8) {
                        Text("Route unavailable")
                            .font(.headline)
                        Text(error)
                            .font(.caption)
                            .foregroundStyle(.secondary)
                            .multilineTextAlignment(.center)
                        Button("Retry") {
                            Task { await vm.calculate(from: origin, to: destination) }
                        }
                        .buttonStyle(.borderedProminent)
                    }
                    .padding()
                    .frame(maxWidth: .infinity)
                    .background(.ultraThinMaterial)
                    .clipShape(RoundedRectangle(cornerRadius: 16))
                    .padding()
                } else if let route = vm.route {
                    RouteInfoCard(route: route) {
                        vm.showSteps.toggle()
                    }
                    .padding()
                }
            }
        }
        .sheet(isPresented: $vm.showSteps) {
            StepsSheet(steps: vm.steps)
                .presentationDetents([.medium, .large])
        }
        .task {
            await vm.calculate(from: origin, to: destination)
        }
        .navigationTitle("Route Planning")
        .navigationBarTitleDisplayMode(.inline)
    }
}

// MARK: - Route Info Card

struct RouteInfoCard: View {
    let route: MKRoute
    let onStepsTapped: () -> Void

    private var distanceText: String {
        let formatter = MKDistanceFormatter()
        formatter.unitStyle = .abbreviated
        return formatter.string(fromDistance: route.distance)
    }

    private var durationText: String {
        let formatter = DateComponentsFormatter()
        formatter.allowedUnits = [.hour, .minute]
        formatter.unitsStyle = .abbreviated
        return formatter.string(from: route.expectedTravelTime) ?? "--"
    }

    var body: some View {
        HStack {
            VStack(alignment: .leading, spacing: 4) {
                Text(route.name.isEmpty ? "Best Route" : route.name)
                    .font(.headline)
                HStack(spacing: 12) {
                    Label(durationText, systemImage: "clock")
                    Label(distanceText, systemImage: "arrow.triangle.swap")
                }
                .font(.subheadline)
                .foregroundStyle(.secondary)
            }
            Spacer()
            Button("Steps") { onStepsTapped() }
                .buttonStyle(.bordered)
                .accessibilityLabel("Show turn-by-turn steps")
        }
        .padding()
        .background(.ultraThinMaterial)
        .clipShape(RoundedRectangle(cornerRadius: 16))
    }
}

// MARK: - Steps Sheet

struct StepsSheet: View {
    let steps: [MKRoute.Step]

    var body: some View {
        NavigationStack {
            List(Array(steps.enumerated()), id: \.offset) { index, step in
                HStack(alignment: .top, spacing: 12) {
                    ZStack {
                        Circle()
                            .fill(.blue)
                            .frame(width: 28, height: 28)
                        Text("\(index + 1)")
                            .font(.caption.bold())
                            .foregroundStyle(.white)
                    }
                    VStack(alignment: .leading, spacing: 4) {
                        Text(step.instructions)
                            .font(.subheadline)
                        if step.distance > 0 {
                            let fmt = MKDistanceFormatter()
                            Text(fmt.string(fromDistance: step.distance))
                                .font(.caption)
                                .foregroundStyle(.secondary)
                        }
                    }
                }
                .padding(.vertical, 4)
                .accessibilityElement(children: .combine)
            }
            .navigationTitle("Directions")
            .navigationBarTitleDisplayMode(.inline)
        }
    }
}

// MARK: - Preview

#Preview {
    NavigationStack {
        RoutePlanningView()
    }
}

How it works

  1. MKDirections.Request configuration — The calculate(from:to:transportType:) method on the view model builds an MKDirections.Request, setting .source, .destination, and .transportType. Passing .automobile requests turn-by-turn driving directions; you can swap in .walking or .transit as needed.
  2. Async route calculationtry await directions.calculate() is the Swift concurrency–native API added in iOS 15. It returns an MKDirections.Response containing an array of MKRoute objects. We take routes.first — the fastest route when requestsAlternateRoutes is false.
  3. MapPolyline overlay — The iOS 17 MapPolyline view accepts route.polyline directly and renders inside the new declarative Map content builder. The .stroke(.blue.gradient, style:) modifier applies a gradient stroke with rounded caps, giving routes a polished appearance without any MKOverlayRenderer subclassing.
  4. Dynamic camera bounds — The cameraBounds computed property converts the route polyline's boundingMapRect into a MapCameraBounds, automatically framing the entire route when it's first rendered without any manual region math.
  5. Turn-by-turn steps — Each MKRoute.Step exposes .instructions (a human-readable string) and .distance. The StepsSheet lists them in order, formatted with MKDistanceFormatter for locale-aware distance strings.

Variants

Walking route with multiple waypoints

Chain multiple MKDirections calls sequentially — one per segment — and concatenate the resulting polylines into an array of MapPolyline overlays.

struct WaypointRoute: View {
    // Waypoints: A → B → C
    let waypoints: [CLLocationCoordinate2D]
    @State private var routes: [MKRoute] = []

    var body: some View {
        Map {
            ForEach(Array(routes.enumerated()), id: \.offset) { _, route in
                MapPolyline(route.polyline)
                    .stroke(.purple, lineWidth: 4)
            }
            ForEach(Array(waypoints.enumerated()), id: \.offset) { i, coord in
                Marker("Stop \(i + 1)", coordinate: coord)
                    .tint(i == 0 ? .green : (i == waypoints.count - 1 ? .red : .blue))
            }
        }
        .task { await buildSegments() }
    }

    func buildSegments() async {
        guard waypoints.count >= 2 else { return }
        var calculated: [MKRoute] = []
        for i in 0..<(waypoints.count - 1) {
            let req = MKDirections.Request()
            req.source = MKMapItem(placemark: MKPlacemark(coordinate: waypoints[i]))
            req.destination = MKMapItem(placemark: MKPlacemark(coordinate: waypoints[i + 1]))
            req.transportType = .walking
            if let r = try? await MKDirections(request: req).calculate().routes.first {
                calculated.append(r)
            }
        }
        routes = calculated
    }
}

#Preview {
    WaypointRoute(waypoints: [
        CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194),
        CLLocationCoordinate2D(latitude: 37.7600, longitude: -122.4350),
        CLLocationCoordinate2D(latitude: 37.7500, longitude: -122.4180)
    ])
}

ETA with live traffic via expectedTravelTime

MKRoute.expectedTravelTime returns a traffic-aware duration in seconds when transportType is .automobile. Add it to Date.now and format with Date.FormatStyle to show an arrival time: let eta = Date.now.addingTimeInterval(route.expectedTravelTime) then display with Text(eta, style: .time). Apple Maps–sourced traffic data is included automatically on device; no additional entitlement is required.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement route planning in SwiftUI for iOS 17+.
Use MKDirections with async/await to calculate routes between two MKMapItems.
Render the polyline using MapPolyline with a gradient stroke on a SwiftUI Map.
Display a bottom card showing travel time and distance (MKDistanceFormatter).
Show turn-by-turn steps (MKRoute.Step) in a sheet.
Make it accessible (VoiceOver labels on route info card and steps list).
Add a #Preview with realistic sample data (SF to Apple Park).

In Soarias's Build phase, paste this prompt into the Implementation screen to scaffold the full route feature in your project — Soarias wires it into your existing MapKit setup and ensures location permissions are already declared in Info.plist.

Related

FAQ

Does this work on iOS 16?

MKDirections and its async API are available from iOS 15, but the declarative MapPolyline overlay and the new Map content builder require iOS 17. On iOS 16 you can still calculate routes using the same MKDirections code, but rendering the polyline requires wrapping MKMapView in a UIViewRepresentable with a custom MKPolylineRenderer.

Can I request multiple alternate routes simultaneously?

Yes. Set request.requestsAlternateRoutes = true before calling calculate(). The response's routes array will contain up to three alternatives, sorted by Apple's ranking (fastest first). Render each with its own MapPolyline — use different opacity or stroke colors (e.g., .blue for the primary, .gray for alternates) to distinguish them visually.

What's the UIKit equivalent?

In UIKit you call the same MKDirections API but render the result via MKMapView.addOverlay(_:) and implement mapView(_:rendererFor:) in your MKMapViewDelegate, returning an MKPolylineRenderer with your desired stroke color and width. The SwiftUI approach eliminates all of that delegate boilerplate.

Last reviewed: 2026-05-12 by the Soarias team.

```