Build a location sensitive iOS widget

When I started working on Padlok widgets, my initial goal was clear: showing the current closest address using location. And if location based widget is supported since widgets exist, setting it up is not as straightforward as it seems.

The theory

First good point: we’re not entirely in the dark here. Apple Developer documentation provides a fairly good article about accessing location information in widgets. We can summarize those in a few bullet points:

That’s a lot of intel to ingest, but we’re still far from a working widget. And with no code sample, we have a lot of blanks to fill.

The entry

Since our entry need to manage a few kind of location related errors, I chose an approach using custom Swift errors, and a value based on a Result object.

struct LocationEntry: TimelineEntry {
    enum Error: Swift.Error {
        /// Location is not authorized for the widget.
        case unauthorized
        /// For some other reason, location was not available.
        case noLocation
    }
    typealias Value = Result<CLLocation, Error>

    let date: Date
    let value: Value
}

Above is a very basic example using a CLLocation, but any value that you can fetch based on location would fit.

The timeline

The widget timeline is important to consider properly, but timelines are entirely based on the Date object to decide what is the appropriate item to show. This is very handy when it comes to showing information that are time sensitive, but way less when it comes to location sensitive data.

As it comes for the refresh policy of the timeline, you have three options:

Nothing related to location here.

Thankfully, the NSWidgetWantsLocation key you add in the widget is not only making the location available for the widget, or prompting the end user for location permission in the widget. It’s also responsible for refreshing the timeline automatically when location have changed significantly.

It means that the policy of the timeline is important if your data is also time sensitive, but is ignore for location change, as you cannot anticipate a user location in advance (or at least, I hope you can’t!).

For Padlok, since the data is not time sensitive, I can opt to a never renewal policy for the timeline, and only the location will trigger the widget for a new timeline and entries.

The provider

Now that we have chosen our provider refresh policy, and setup everything, we need to access the location itself within the Timeline Provider.

Our code would look like this:

func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
    // TODO: calculate value according to location, and state
    let entry = LocationEntry(date: .now, value: .failure(.noLocation))
    let timeline = Timeline(entries: [entry], policy: .never)
    completion(timeline)
}

But accessing a location with a completion handler … is not as easy as it seems. That because CLLocationManager is based on a delegate callback to provide the location.

What we would need would be some kind of let location = try await locationManager.requestLocation() method.

With that, the provider will be easy to build:

func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
    Task {
        let value: Entry.Value
        let location = AsyncLocation()
        if location.isAuthorizedForWidgetUpdates {
            do {
                let location = try await location.requestLocation()
                value = .success(location)
            } catch {
                value = .failure(.noLocation)
            }
        } else {
            value = .failure(.unauthorized)
        }
        let entry = LocationEntry(date: .now, value: value)
        let timeline = Timeline(entries: [entry], policy: .never)
        completion(timeline)
    }
}

Let’s build this AsyncLocation magic!

Get location with Swift Concurrency

And the best solution to do this in my opinion is using the incredible Combine framework! And we can easily monitor for upcoming location value with the magical PassthroughSubject

The core of the object would look like this:

final class AsyncLocation: NSObject {
    private let manager: CLLocationManager
    private let locationSubject: PassthroughSubject<Result<CLLocation, Error>, Never> = .init()

    var isAuthorizedForWidgetUpdates: Bool {
        manager.isAuthorizedForWidgetUpdates
    }

    override init() {
        manager = CLLocationManager()
        super.init()
        manager.delegate = self
    }
}

extension AsyncLocation: CLLocationManagerDelegate {
    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        guard let location = locations.last else {
            return
        }
        locationSubject.send(.success(location))
    }

    func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
        locationSubject.send(.failure(error))
    }
}

The implementation is not that complex, with a few subtleties:

Finally, we just have to implement the requestLocation() async throws -> CLLocation method:

extension AsyncLocation {
    enum Errors: Error {
        case missingOutput
    }

    func requestLocation() async throws -> CLLocation {
        let result: Result<CLLocation, Error> = try await withUnsafeThrowingContinuation { continuation in
            var cancellable: AnyCancellable?
            var didReceiveValue = false
            cancellable = locationSubject.sink(
                receiveCompletion: { _ in
                    if !didReceiveValue {
                        // subject completed without a value…
                        continuation.resume(throwing: Errors.missingOutput)
                    }
                },
                receiveValue: { value in
                    // Make sure we only send a value once!
                    guard !didReceiveValue else {
                        return
                    }
                    didReceiveValue = true
                    // Cancel current sink
                    cancellable?.cancel()
                    // We either got a location or an error
                    continuation.resume(returning: value)
                }
            )
            // Now that we monitor locationSubject, ask for the location
            manager.requestLocation()
        }
        switch result {
        case .success(let location):
            // We got the location!
            return location
        case .failure(let failure):
            // We got an error :(
            throw failure
        }
    }
}

It might be a little technical if you never played with Combine, but the magic lies in a few lines:

This approach is very close to what is implemented in Padlok. It only lacks a few things, like a timeout mechanism that could be very handy in a scenario where refreshing might be way too long for a widget extension to execute.

Final thoughts

Completing the widget is not as hard as it seems, but do not forget to:

You know have no excuse to bring the location context in your Widget. I love getting relevant data of my favorite apps based on my current context, like location; and I can’t wait to see your own location sensitive widgets on my homescreen.

Padlok app icon

Padlok

Still looking for the codes?

Learn more

Don’t miss a thing!

Don't miss any of my indie dev stories, app updates, or upcoming creations!
Stay in the loop and be the first to experience my apps, betas and stories of my indie journey.

Thank you for registering!
You’ll retrieve all of my latest news!

Your email is sadly invalid.
Can you try again?

An error occurred while registering.
Please try again