```html SwiftUI: How to Build Location Tracking (iOS 17+, 2026)

How to Build Location Tracking in SwiftUI

iOS 17+ Xcode 16+ Advanced APIs: CoreLocation Updated: May 11, 2026
TL;DR

Wrap CLLocationManager in an @Observable class, request whenInUse authorization, then drive a Task that loops over CLLocationUpdate.liveUpdates() — iOS 17's modern async replacement for the delegate pattern.

import CoreLocation

@Observable
final class LocationManager {
    var coordinate: CLLocationCoordinate2D?
    private let manager = CLLocationManager()

    func start() async {
        manager.requestWhenInUseAuthorization()
        for await update in CLLocationUpdate.liveUpdates() {
            guard let loc = update.location else { continue }
            coordinate = loc.coordinate
        }
    }
}

struct ContentView: View {
    @State private var loc = LocationManager()
    var body: some View {
        Text(loc.coordinate.map { "\($0.latitude), \($0.longitude)" }
             ?? "Waiting…")
            .task { await loc.start() }
    }
}

Full implementation

The approach uses iOS 17's CLLocationUpdate.liveUpdates() async stream rather than the old delegate pattern, keeping everything on the Swift concurrency system. An @Observable LocationManager publishes the latest coordinate and authorization status, while a .task modifier keeps the stream alive for exactly as long as the view is on screen. Background location and accuracy tuning are also demonstrated.

import SwiftUI
import CoreLocation
import MapKit

// MARK: - Location Manager

@Observable
final class LocationManager {
    // Published state
    var coordinate: CLLocationCoordinate2D?
    var altitude: Double?
    var speed: Double?
    var heading: Double?
    var authorizationStatus: CLAuthorizationStatus = .notDetermined
    var errorMessage: String?

    private let manager = CLLocationManager()
    private var streamTask: Task<Void, Never>?

    // MARK: Start streaming

    func start(accuracy: CLLocationAccuracy = kCLLocationAccuracyBest) async {
        // 1. Request permission on first call
        manager.desiredAccuracy = accuracy
        manager.requestWhenInUseAuthorization()

        // 2. Mirror current authorization
        authorizationStatus = manager.authorizationStatus

        // 3. Stream live updates (iOS 17+)
        let updates = CLLocationUpdate.liveUpdates()
        do {
            for try await update in updates {
                // Reflect authorization changes mid-stream
                authorizationStatus = manager.authorizationStatus

                guard let location = update.location else { continue }
                coordinate = location.coordinate
                altitude   = location.altitude
                speed      = max(location.speed, 0)
                heading    = location.course >= 0 ? location.course : nil
                errorMessage = nil
            }
        } catch {
            errorMessage = error.localizedDescription
        }
    }

    func stop() {
        streamTask?.cancel()
        streamTask = nil
    }
}

// MARK: - Coordinate View

struct LocationTrackingView: View {
    @State private var locationManager = LocationManager()

    var body: some View {
        NavigationStack {
            VStack(spacing: 24) {
                authorizationBanner

                if let coord = locationManager.coordinate {
                    coordinateCard(coord)
                    metricsRow
                } else {
                    waitingView
                }

                if let err = locationManager.errorMessage {
                    Text(err)
                        .foregroundStyle(.red)
                        .font(.caption)
                        .padding(.horizontal)
                }
            }
            .padding()
            .navigationTitle("Live Location")
            .navigationBarTitleDisplayMode(.inline)
        }
        // Start streaming when the view appears; auto-cancelled on disappear
        .task {
            await locationManager.start(accuracy: kCLLocationAccuracyNearestTenMeters)
        }
    }

    // MARK: Sub-views

    @ViewBuilder
    private var authorizationBanner: some View {
        switch locationManager.authorizationStatus {
        case .denied, .restricted:
            Label("Location access denied. Enable in Settings.", systemImage: "location.slash")
                .font(.caption)
                .foregroundStyle(.orange)
                .padding(8)
                .background(.orange.opacity(0.1), in: RoundedRectangle(cornerRadius: 8))
        case .notDetermined:
            Label("Requesting permission…", systemImage: "location")
                .font(.caption)
                .foregroundStyle(.secondary)
        default:
            Label("Location active", systemImage: "location.fill")
                .font(.caption)
                .foregroundStyle(.green)
        }
    }

    private func coordinateCard(_ coord: CLLocationCoordinate2D) -> some View {
        VStack(spacing: 6) {
            Text("Current Position")
                .font(.caption)
                .foregroundStyle(.secondary)
                .accessibilityHidden(true)
            Text(String(format: "%.6f°, %.6f°", coord.latitude, coord.longitude))
                .font(.title2.monospacedDigit())
                .fontWeight(.semibold)
                .accessibilityLabel("Latitude \(String(format: "%.4f", coord.latitude)), longitude \(String(format: "%.4f", coord.longitude))")
        }
        .frame(maxWidth: .infinity)
        .padding()
        .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16))
    }

    private var metricsRow: some View {
        HStack(spacing: 16) {
            metricCell(
                value: locationManager.altitude.map { String(format: "%.0f m", $0) } ?? "—",
                label: "Altitude",
                icon: "arrow.up.to.line"
            )
            metricCell(
                value: locationManager.speed.map { String(format: "%.1f m/s", $0) } ?? "—",
                label: "Speed",
                icon: "speedometer"
            )
            metricCell(
                value: locationManager.heading.map { String(format: "%.0f°", $0) } ?? "—",
                label: "Course",
                icon: "location.north.line"
            )
        }
    }

    private func metricCell(value: String, label: String, icon: String) -> some View {
        VStack(spacing: 4) {
            Image(systemName: icon)
                .foregroundStyle(.tint)
            Text(value)
                .font(.headline.monospacedDigit())
            Text(label)
                .font(.caption2)
                .foregroundStyle(.secondary)
        }
        .frame(maxWidth: .infinity)
        .padding(.vertical, 12)
        .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12))
        .accessibilityElement(children: .combine)
        .accessibilityLabel("\(label): \(value)")
    }

    private var waitingView: some View {
        VStack(spacing: 12) {
            ProgressView()
            Text("Acquiring location…")
                .foregroundStyle(.secondary)
        }
        .frame(maxWidth: .infinity, minHeight: 120)
        .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16))
    }
}

// MARK: - Preview

#Preview {
    LocationTrackingView()
}

How it works

  1. @Observable + stored CLLocationManager — The manager is marked @Observable (iOS 17's macro-based observation system), so SwiftUI re-renders only the views that actually read the properties that change, without any manual @Published wiring.
  2. CLLocationUpdate.liveUpdates() async stream — Introduced in iOS 17, this replaces the CLLocationManagerDelegate pattern with a clean for try await loop. Each iteration delivers a CLLocationUpdate whose .location property is nil if the device is stationary and no new fix is available.
  3. .task { await loc.start() } lifetime binding — SwiftUI automatically cancels the structured concurrency Task when the view disappears, stopping the GPS hardware and saving battery with no manual cleanup code.
  4. Permission flow inside start()requestWhenInUseAuthorization() is called just before the stream begins. Authorization status is re-read after each update to detect mid-session revocations and display the banner in real time.
  5. Accessibility labels on all dynamic data — The coordinate card uses .accessibilityLabel with human-friendly text ("Latitude 37.3349") so VoiceOver users hear readable values instead of raw decimal strings.

Variants

Background location updates

// 1. Add "Location updates" background mode in Xcode → Signing & Capabilities
// 2. Add NSLocationAlwaysAndWhenInUseUsageDescription to Info.plist

extension LocationManager {
    func startBackground() async {
        manager.allowsBackgroundLocationUpdates = true
        manager.pausesLocationUpdatesAutomatically = false
        manager.requestAlwaysAuthorization()

        // Use the same async stream — background delivery is automatic
        for await update in CLLocationUpdate.liveUpdates() {
            guard let loc = update.location else { continue }
            coordinate = loc.coordinate
            // Persist to SwiftData, post a silent notification, etc.
        }
    }
}

// In your SwiftUI App scene:
// .backgroundTask(.urlSession("location")) { ... } can also receive
// significant-location-change wakeups without draining battery.

Significant-location-change monitoring (battery-friendly)

For apps that only need coarse position updates (e.g., weather or local news), use manager.startMonitoringSignificantLocationChanges() via the delegate API instead of liveUpdates(). This wakes the app only when the device moves ~500 m, consuming far less power than continuous GPS. Wrap it in a CLLocationManagerDelegate adapter class that feeds a AsyncStream<CLLocation> so you can still use async/await syntax.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement location tracking in SwiftUI for iOS 17+.
Use CoreLocation: CLLocationUpdate.liveUpdates(), CLLocationManager,
CLAuthorizationStatus, kCLLocationAccuracyBest.
Make it accessible (VoiceOver labels on coordinate and metric cells).
Add a #Preview with realistic sample data.

Drop this prompt into the Soarias Build phase after your screens are scaffolded — it integrates cleanly into the generated LocationManager service layer without touching your existing view hierarchy.

Related

FAQ

Does this work on iOS 16?

CLLocationUpdate.liveUpdates() is iOS 17-only. For iOS 16 support, wrap it in if #available(iOS 17, *) and fall back to CLLocationManagerDelegate with startUpdatingLocation(). The @Observable macro itself is also iOS 17+; use ObservableObject + @Published on iOS 16.

How do I stop location updates when the user leaves the map screen?

Use SwiftUI's .task { await locationManager.start() } modifier — structured concurrency automatically cancels the Task (and therefore the async for-loop) when the view disappears. No onDisappear cleanup required. If you drive the stream from a longer-lived object (e.g., an app-level service), call streamTask.cancel() explicitly in onDisappear.

What's the UIKit equivalent?

In UIKit you'd implement CLLocationManagerDelegate and its locationManager(_:didUpdateLocations:) callback, then call manager.startUpdatingLocation(). The delegate fires on the main thread by default. The SwiftUI approach shown here is cleaner because the async stream eliminates the delegate boilerplate and integrates with Swift's structured concurrency so cancellation is automatic.

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

```