Kyle Young

← All Posts

How I Handled "Always" Location Access in a SwiftUI App

When I was building an app that passively tracks which venues users are at, I quickly realized the whole thing falls apart without "Always" location access. The background geofencing, the significant location changes that keep things running when the app is killed — both require Always. It was a hard requirement.

That meant thinking carefully about how to ask for it, what to do when users granted less than I needed, and how to handle every state the OS could put me in. Here's how I approached each part.

Why "Always" is a hard requirement

Apple's location permission model has two tiers. "When In Use" gives you location updates while the app is in the foreground or actively running in the background. "Always" gives you access to significant location change monitoring and region monitoring, both of which can wake or relaunch a killed app.

For an app that needs to detect venue entry and exit passively, "When In Use" stops working the moment the app closes.

Initializing the LocationManager early

The first thing I got wrong was initializing LocationManager inside a view. If iOS relaunches the app in the background due to a location event, it goes through AppDelegate first. Your LocationManager needs to be alive by then. Initialize it after views load and the event is gone before your code ever runs.

The fix is to initialize it in AppDelegate, before any views exist:

func application(_ application: UIApplication,
                 didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    _ = LocationManager.shared
    return true
}

One line, but it determines whether background events get delivered at all.

The permission state machine

CLAuthorizationStatus has five states worth handling differently:

Most apps treat these as binary. The difference between .notDetermined and .denied matters: .notDetermined allows a system prompt; .denied sends the user to Settings.

The hard gate

I decided early to gate the main UI on Always access. The core feature requires it, and letting users in with partial access just creates confusion. The gate in ContentView:

if locationManager.authorizationStatus != .authorizedAlways {
    LocationRequestView()
} else {
    MainAppView()
}

This applies both during onboarding and after. If a user downgrades their permission in Settings and comes back to the app, they see the request screen again instead of a broken experience.

The WhenInUse to Always upgrade path

iOS supports upgrading from "When In Use" to "Always". Calling requestAlwaysAuthorization() after the user already granted "When In Use" triggers the upgrade prompt. I handle this in two places.

First, in LocationRequestView on appear, I call requestAlwaysAuthorization() if the status is .notDetermined or .authorizedWhenInUse. For first-time users this triggers the system prompt immediately. For users who already granted partial access, it shows the upgrade dialog.

.onAppear {
    let status = LocationManager.shared.authorizationStatus
    if status == .notDetermined || status == .authorizedWhenInUse {
        LocationManager.shared.requestAlwaysAuthorization()
    }
}

Second, I watch for status changes in ContentView. If the user grants "When In Use" through the system prompt, I immediately ask to upgrade:

.onChange(of: locationManager.authorizationStatus) { _, status in
    if status == .authorizedWhenInUse {
        locationManager.requestAlwaysAuthorization()
    }
}

When the user has already denied

After a user denies "Always" or denies location entirely, iOS blocks any further system prompts. The only path is Settings.

LocationRequestView handles this with a step-by-step explanation and an "Open Settings" button:

Button("Open Settings") {
    if let url = URL(string: UIApplication.openSettingsURLString) {
        UIApplication.shared.open(url)
    }
}

The instructions in the view walk through exactly what to tap: Settings, then Location, then Always. Users who actually want the feature working will follow these steps.

The Settings return problem

There's a subtle iOS behavior that caught me: when a user changes location permissions in Settings and comes back to the app, didChangeAuthorization sometimes skips entirely. Your published authorizationStatus stays stale and the UI stays on the request screen even though the user just granted Always.

The fix is to re-read the authorization status explicitly whenever the app becomes active:

.onChange(of: scenePhase) { _, newPhase in
    if newPhase == .active {
        locationManager.refreshAuthorizationStatus()
    }
}

And in LocationManager:

func refreshAuthorizationStatus() {
    authorizationStatus = locationManager.authorizationStatus
}

Skip this and users who grant permission in Settings come back to the request screen still showing. They assume it broke and stop trying.

The same problem on init

A related issue happens on launch. If you declare authorizationStatus as @Published var authorizationStatus: CLAuthorizationStatus = .notDetermined, any view observing it will briefly see .notDetermined even if the user granted "Always" a month ago.

The fix is to read the real status synchronously in init before any observer sees the value:

override init() {
    super.init()
    locationManager.delegate = self
    authorizationStatus = locationManager.authorizationStatus // read real value immediately
}

Any view depending on authorizationStatus renders correctly on the first pass instead of flashing the wrong state.

What the full flow looks like

Every state is handled and the user always knows what's going on.