Search code examples
swiftsiriios16appintents

How to work with user location in iOS 16 App Intents?


I'm working on an App Shortcut using the new AppIntents framework in iOS 16 and I'm trying to get the user's current location, everything is enabled and set-up correctly with the permissions

func perform() async throws -> some IntentResult {
    //Request User Location
    IntentHelper.sharedInstance.getUserLocation()
    
    guard let userCoords = IntentHelper.sharedInstance.currentUserCoords else { throw IntentErrors.locationProblem }
    
    //How to wait for location??
    
    return .result(dialog: "Worked! Current coords are \(userCoords)") {
         IntentSuccesView()
     }
}

And here is the IntentHelper class

    class IntentHelper: NSObject {
    
    static let sharedInstance = IntentHelper()
    var currentUserCoords: CLLocationCoordinate2D?
    
    private override init() {}
    
    func getUserLocation() {
        DispatchQueue.main.async {
            let locationManager = CLLocationManager()
            locationManager.delegate = self
            print("FINALLY THIS IS IT")
            self.currentUserCoords = locationManager.location?.coordinate
            print(self.currentUserCoords)
        }
    }
}

extension IntentHelper: CLLocationManagerDelegate {
    func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
        print(error)
        manager.stopUpdatingLocation()
    }
}

Problem is, this sometimes, very rarely works, most of the times it prints nil, so how would you go about waiting for the location?


Solution

  • The problem is you are trying to get the location synchronously, so it only works if locationManager.location was already not nil by the time you ask for it. Instead this operation may take time and is therefore asynchronous.

    So the basic flow is like this:

    • Check permissions (yes, you have to do it every time, as user may take away the permissions a any point)
    • And tell CLLocationManager to start resolving user location
    • After that just listen for result via locationManager(:, didUpdateLocations:) event of the CLLocationManagerDelegate, which you need to implement (in your case in the same class, as you already implemented the failure case in extension).

    On top of that, you probably want to wait for location update (either coordinates or failure) inside your func perform().

    So I would say you need to have something like this in func perform():

    // Wait for coordinates
    guard let userCoords = await IntentHelper.sharedInstance.getCurrentCoordinates() else { ... }
    

    where the getCurrentCoordinates() is just an async wrapper, something like:

    func getCurrentCoordinates() async -> CLLocationCoordinate2D? {
        await withCheckedContinuation { continuation in
           getCurrentCoordinates() { coordinates in
                continuation.resume(returning: coordinates)
            }
        }
    }
    

    while getCurrentCoordinates(callback:) will be something like:

    class IntentHelper {
        var callback: ((CLLocationCoordinate2D?) -> Void)?
        //...
        func getCurrentCoordinates(callback: @escaping (CLLocationCoordinate2D?) -> Void) {
    
            // Step 1: check permissions
            let status = CLLocationManager.authorizationStatus()
            guard status == .authorizedAlways || status == .authorizedWhenInUse else {
                // you can't ask for permissions
                callback(nil)
                return
    
            // Step 2: preserve callback and request location
            self.callback = callback
            locationManager?.requestLocation()
        }
    }
    

    Now all you need to do is wait for locationManager(:, didUpdateLocations:) or locationManager(:, didFailWithError:) to happen:

    extension IntentHelper: CLLocationManagerDelegate {
        func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
            
            // Pass the result (no location info) back to the caller
            self.callback?(nil)
        }
        
        func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
    
            // Pass the result location back to the caller
            // For simplicity lets say we take the first location in list
            self.callback?(locations.first)
        }
        
    }
    

    Note: this is a draft code, I didn't try to compile it, so you may need to fix some compilation errors.

    Here's a nice walkthrough of the whole scenario (which also shows a nicer code organization (i.e. how to ask for permissions, etc).