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:
- Widget extension Info.plist requires
NSWidgetWantsLocation
key set toYES
. - Parent application Info.plist requires either
NSLocationWhenInUseUsageDescription
orNSLocationAlwaysAndWhenInUseUsageDescription
keys with the proper usage string. - Calling
CLLocationManager.isAuthorizedForWidgetUpdates
allows to check if we are authorized to access location information in the widget extension. - It’s good UX practice to separate widgets that uses location and the other widgets in different app extensions to prevent asking for user location when the widget added is unrelated.
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:
atEnd
: Ask the provider for a new timeline when the last entry was consumednever
: Never trigger the provider again. Invalidation will be manualafter(_ date: Date)
: Trigger the provider after a specific date.
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:
- We passthrough to access the manager
isAuthorizedForWidgetUpdates
property. It must be called once, and once only! - We use the delegate pattern of CLLocationManager, setting the delegate right in the initializer
- We send the location value when we get one though the
PassthroughSubject
- We use a
Result
object, and “send” the error, because sending a completion would end the Passthrough entirely. And it might not be what we’re looking for here.
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:
sink
will be called on completion, or when a value is retrieved- because we cancel the sink when we get a value, and that we have an extra
didReceiveValue
check, we are sure to get only one value - thanks to the
receiveCompletion
, we also send something when no value was send, on termination. - since we only send one value, and we always send it, we can rely on
withUnsafeThrowingContinuation
instead ofwithCheckedThrowingContinuation
. - once we are monitoring the value arrival thanks to sink, we can ask a location from the
CLLocationManager
object. - When we either get a location, or an error, it get sent to the
PassthroughSubject
, linked to the sink, and the await on the withUnsafeThrowingContinuation is fulfilled. - We then send the result, or throw the error.
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:
- Separate widgets that requires location in a different extension than those how do not require it.
- Provide a placeholder that rely on no underlying data: it need to be as fast as possible, and beautiful because user might see it more often when location is loading
- Provide a snapshot for the widget library that have an hard coded location (or the last location you know in a user default), because waiting for the location to load is not a good user experience in the library. And the location permission might not allow it … yet.
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
Still looking for the codes?
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.