How to Build Location Tracking in SwiftUI
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
-
@Observable+ storedCLLocationManager— 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@Publishedwiring. -
CLLocationUpdate.liveUpdates()async stream — Introduced in iOS 17, this replaces the CLLocationManagerDelegate pattern with a cleanfor try awaitloop. Each iteration delivers aCLLocationUpdatewhose.locationproperty isnilif the device is stationary and no new fix is available. -
.task { await loc.start() }lifetime binding — SwiftUI automatically cancels the structured concurrencyTaskwhen the view disappears, stopping the GPS hardware and saving battery with no manual cleanup code. -
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. -
Accessibility labels on all dynamic data — The coordinate card uses
.accessibilityLabelwith 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
-
Missing Info.plist key crashes silently. Forgetting
NSLocationWhenInUseUsageDescriptioncauses a silent crash (SIGABRT) with no useful console log. Add the key before calling any CoreLocation API. -
CLLocationUpdate.liveUpdates()is iOS 17+ only. If your deployment target is still iOS 16, wrap it in an#available(iOS 17, *)check and fall back to the delegate-basedstartUpdatingLocation(). The new API is not back-deployed. -
Simulator accuracy is synthetic. The Simulator's "City Bicycle Ride" and other location scenarios produce clean data; real devices show accuracy radii of 5–65 m. Always test on hardware and handle
location.horizontalAccuracybefore trusting a fix (discard values > 50 m for precision use cases). -
Never start location on the main actor.
CLLocationManagermust be created and used on a consistent thread. Mark your manager class@MainActoror always use it from a dedicated actor to avoid data races flagged by the Swift concurrency checker.
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.