How to Build Route Planning in SwiftUI
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
-
MKDirections.Request configuration — The
calculate(from:to:transportType:)method on the view model builds anMKDirections.Request, setting.source,.destination, and.transportType. Passing.automobilerequests turn-by-turn driving directions; you can swap in.walkingor.transitas needed. -
Async route calculation —
try await directions.calculate()is the Swift concurrency–native API added in iOS 15. It returns anMKDirections.Responsecontaining an array ofMKRouteobjects. We takeroutes.first— the fastest route whenrequestsAlternateRoutesisfalse. -
MapPolyline overlay — The iOS 17
MapPolylineview acceptsroute.polylinedirectly and renders inside the new declarativeMapcontent builder. The.stroke(.blue.gradient, style:)modifier applies a gradient stroke with rounded caps, giving routes a polished appearance without anyMKOverlayRenderersubclassing. -
Dynamic camera bounds — The
cameraBoundscomputed property converts the route polyline'sboundingMapRectinto aMapCameraBounds, automatically framing the entire route when it's first rendered without any manual region math. -
Turn-by-turn steps — Each
MKRoute.Stepexposes.instructions(a human-readable string) and.distance. TheStepsSheetlists them in order, formatted withMKDistanceFormatterfor 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
-
iOS 17 MapPolyline only: The declarative
MapPolylineview is unavailable before iOS 17. On iOS 16 you needUIViewRepresentablewrappingMKMapViewwith a delegate overlay renderer — a significant amount of boilerplate. Gate your deployment target accordingly. -
MKDirections rate limiting: Apple throttles
MKDirectionscalls per app per minute. Never callcalculate()in a tight loop or on every keystroke; debounce destination input by at least 800 ms and cancel in-flight requests withMKDirections.cancel()before issuing a new one. -
Missing NSLocationWhenInUseUsageDescription: Using
MKMapItem.forCurrentLocation()as the route source requires location permission. If the Info.plist key is absent the app silently fails with a MapKit error rather than crashing. Always prefer an explicit coordinate for testing to rule out permission issues. -
Accessibility — polyline has no inherent VoiceOver content: The
MapPolylineoverlay is purely visual. Expose route information to VoiceOver users via the steps sheet and by setting.accessibilityLabelon the route card with distance and duration text.
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.