9.Keeping Complications Updated¶
Written by Scott Grosch
Now that your complications are available to place on the watch face, you have one last consideration. How do you ensure the displayed data is up to date?
Background Download¶
When your complication’s timeline runs out of data, or even before, you’ll want to initiate a background download. While your first instinct is likely to call one of the dataTask
methods from URLSession
, that won’t work. watchOS might pause and restart your widget extension multiple times during the download. Instead, you’ll have to create a download task.
Refactor CoOpsApi¶
Now that both the app and the widget extension need to download data, you’ll need to move some files from TideWatch Watch App into Shared. The main file you need is CoOpsApi, but moving it has a cascading effect. Move the CurrentTideKeys file, as well as the Download and Extensions folders into Shared.
Note
Add the newly moved files to the widget extension’s target membership.
Build your project to ensure the moved files all have the correct target membership.
A few refactorings are required to support downloading data from both projects. The first step you should perform is to declare a property for the root URL in CoOpsApi:
private let rootUrl = "https://api.tidesandcurrents.noaa.gov/api/prod/datagetter"
Mode the assignment of components.queryItems
from url(for:)
into a method of its own:
private func queryItems(
for stationId: MeasurementStation.ID,
from start: Date, to end: Date
) -> [URLQueryItem] {
return [
"product": "predictions",
"units": "metric",
"time_zone": "gmt",
"application": "TideWatch",
"format": "json",
"datum": "mllw",
"interval": "h",
"station": stationId,
"begin_date": "\(Formatters.predictionInputFormatter.string(from: start))",
"end_date": "\(Formatters.predictionInputFormatter.string(from: end))"
].map { .init(name: $0.key, value: $0.value) }
}
Then call that method instead in url(for:)
:
components.queryItems = queryItems(for: stationId, from: start, to: end)
While still in url(for:)
, use the new property in the guard
clause:
var components = URLComponents(string: rootUrl)
The widget extension needs a method to call to initiate a download, so create that next:
public func getWidgetData(
for stationId: MeasurementStation.ID,
using session: URLSession
) {
let end = Calendar.utc.date(byAdding: .hour, value: 1, to: Date.now)!
var components = URLComponents(string: rootUrl)!
components.queryItems = queryItems(for: stationId, from: Date.now, to: end)
let request = URLRequest(url: components.url!)
session
.downloadTask(with: request)
.resume()
}
While the main app can perform a normal data download, as previously mentioned, the widget extension needs to use a downloadTask
.
The final change required in the CoOpsApi file is to make the decoding method public, since it now gets called from the widget extension as well. Add one last method to decode the download data:
public func decodeTide(_ data: Data?) -> [Tide] {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .formatted(Formatters.predictionOutputFormatter)
guard
let data,
let results = try? decoder.decode(TidePredictions.self, from: data),
let predictions = results.predictions
else {
return []
}
return predictions.map { predication in
Tide(on: predication.date, at: predication.height)
}
}
Then, in getLowWaterHeights(for:)
, replace the text from the JSONDecoder
initializer through the levels
assignment with a call to the method you just created:
let levels = decodeTide(data)
That was quite a bit of refactoring, so ensure your project still builds without errors.
Caching Sessions¶
Apple’s developer documentation specifies, in a complicated manner, that you must reuse sessions when possible. Also, you need to reattach any previously interrupted sessions that have started again.
In the TideWatch Widget project, create a file named SessionData that contains the stored session:
import Foundation
final class SessionData {
let session: URLSession
init(session: URLSession) {
self.session = session
}
}
Next, create another file name SessionCache to handle the various caching logic:
import Foundation
// 1
final class SessionCache: NSObject {
// 2
static let shared = SessionCache()
// 3
var sessions: [String: SessionData] = [:]
// 4
private override init() {}
}
In the preceding code:
- The class will eventually implement session delegates, so ensure it inherits from
NSObject
. - Creating a static shared property allows you to use
SessionCache
as a singleton. - Because you might have multiple sessions running at once, you need to be able to find the appropriate
URLSession
based on a session identifier. - By overriding the
init
method and making itprivate
, you ensure that the class can only be used as a singleton, via theshared
property.
Each time the extension needs to download data, it must either reattach an existing URLSession
or create one. Add the following helper method:
// 1
func sessionData(for stationId: MeasurementStation.ID) -> SessionData {
// 2
if let data = sessions[stationId] {
return data
}
// 3
let session = URLSession(
configuration: .background(withIdentifier: stationId),
delegate: self,
delegateQueue: nil
)
// 4
let data = SessionData(session: session)
sessions[stationId] = data
return data
}
Here’s what’s happening:
- Given a station identifier, you’ll provide the required
SessionData
, which includes theURLSession
. - If cached data for the specified station already exists, simply return that.
- If there’s not, you’ll initiate a new background enabled
URLSession
. By specifying thestationId
as the identifier, you ensure that watchOS can reattach an existing download that was previously terminated. - Finally, create a new
SessionData
, store it in the cache and return the object.
Xcode now shows an error as you specified the SessionCache
as the delegate
, so add the following to the end of the file to conform to the delegate:
extension SessionCache: URLSessionDownloadDelegate {
func urlSession(
_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL
) {
}
func urlSession(
_ session: URLSession,
task: URLSessionTask,
didCompleteWithError error: Error?
) {
}
}
Timeline Provider¶
It’s time to initiate a download. Head back to Provider and replace the entire contents of getTimeLine(for:in:completion:)
with the following code:
// 1
let stationId = configuration.station!.identifier!
// 2
let sessionData = SessionCache.shared.sessionData(for: stationId)
// 3
CoOpsApi.shared.getWidgetData(for: stationId, using: sessionData.session)
Here’s why you need that code:
- When asked for a timeline, watchOS will provide the configuration data that you created in the previous chapter. Specifically, you’ll be given
station
that was configured in the TideWatch_Widget intent configuration. It’s safe to force unwrap because this method won’t be called without a station. - You pull the appropriate session to use, possibly having created a new session, via the code you just implemented.
- Finally, watchOS will initiate a background download, using the appropriate session.
While the above is relatively straightforward, a gaping hole is in the code, as provided. Do you see the problem?
Getting a timeline requires calling a completion handler so watchOS knows when the data has been updated. Unfortunately, these methods are not yet async
aware, so it’s time to make some changes to your session code.
Open SessionData and add a new property that conforms to the completion handler:
var downloadCompletion: (([Tide]) -> Void)? = nil
It’s a pretty ugly signature, but you’ve declared an optional property that expects to be provided with an array of Tide
objects and has no return type.
Back in Provider, just before you call the getWidgetData(for:using:)
method, assign that property:
// 1
sessionData.downloadCompletion = { tides in
// 2
var entries = tides.map { tide in
SimpleEntry(date: tide.date, configuration: configuration, tide: tide)
}
// 3
if entries.isEmpty {
entries = [SimpleEntry(date: Date.now, configuration: configuration, tide: nil)]
}
// 4
let oneHour = Calendar.current.date(byAdding: .hour, value: 1, to: Date.now)!
completion(.init(entries: entries, policy: .after(oneHour)))
}
In the preceding code:
- Every time watchOS requests a new timeline, the delegate that will be called at completion should be updated.
- The decoded
Tide
data is converted to an array ofSimpleEntry
objects, which watchOS expects to be passed to the completion handler for presentation at the appropriate time. - It’s important to handle the possibility that no data is available. For instance, the network or the remote server might be down.
- Finally, you call the completion handler with the new data, specifying that watchOS should ask for the next update one hour from now.
In the previous chapter, you assumed a Tide
object was always available, but now there isn’t, so Xcode is showing compiler errors.
Edit SimpleEntry to make the tide
property optional. Doing so means all of your templates must be updated to handle the case of no data. In this chapter’s project materials, I’ve provided updated versions of the templates that you can copy into your project.
You’ll have one last error to fix in EntryView. The new template expects you to pass the station name when using an AccessoryRectangularView
:
AccessoryRectangularView(
tide: entry.tide,
stationName: entry.configuration.station?.displayString
)
Your project should now compile cleanly.
While you’ve implemented the code for downloadCompletion
, which calls the required completion handler, nothing yet calls downloadCompletion
.
Completing the Session¶
In SessionCache, when the delegate methods are complete, you’ll need to call the downloadCompletion
delegate, if it’s set. Note that even if the session completed with an error, you still need to call the delegate to tell watchOS to complete the timeline. Add a new method, as shown below:
private func downloadCompleted(for session: URLSession, data: Data? = nil) {
// 1
guard let stationId = session.configuration.identifier else {
return
}
// 2
let tides = data == nil ? [] : CoOpsApi.shared.decodeTide(data)
// 3
let sessionData = SessionCache.shared.sessionData(for: stationId)
// 4
DispatchQueue.main.async {
sessionData.downloadCompletion?(tides)
sessionData.downloadCompletion = nil
}
}
As you can see, the code is straightforward:
- Retrieve the
stationId
that was assigned during theURLSession
creation. - Decode the supplied
Data
, if it exists. If it’snil
, then an error occurred and you simply default to an empty array. - Find the
SessionData
so you have access to the delegates. - Call the stored
downloadCompletion
on the main thread, if it exists, passing in the decoded tides. While not strictly required, clear out the delegate so it can’t be called a second time.
Finally, update the URLSessionDownloadDelegate
methods:
func urlSession(
_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL
) {
// 1
guard
location.isFileURL,
let data = try? Data(contentsOf: location)
else {
downloadCompleted(for: session)
return
}
// 2
downloadCompleted(for: session, data: data)
}
func urlSession(
_ session: URLSession,
task: URLSessionTask,
didCompleteWithError error: Error?
) {
// 3
downloadCompleted(for: session)
}
In the preceding code:
- When a download task finishes you’ll be provided with a file
URL
pointing to the full contents of the downloaded data. If the provided location isn’t a file or watchOS can’t read the contents, then you call thedownloadCompleted(for:)
method with data, signifying an error occurred. - Otherwise, you pass the data to the completion method for processing.
- In the case of a download error, call the completion method with no data.
You didn’t think you were done, did you?
Background URLSession Events¶
Background network requests are delivered directly to the widget extension, not the containing app. When a background URL session event occurs, you must tell watchOS whether you’re able to process the event, and if so, what to do.
Add a new method to SessionCache to determine what identifier is valid:
func isValid(for stationId: MeasurementStation.ID) -> Bool {
return sessions[stationId] != nil
}
Next, in TideWatch_Widget, add a new modifier to the end of the body
:
.onBackgroundURLSessionEvents { identifier in
// 1
return SessionCache.shared.isValid(for: identifier)
} _: { identifier, completion in
// 2
let data = SessionCache.shared.sessionData(for: identifier)
// 3
data.sessionCompletion = completion
}
Upon receiving a session event:
- Ensure the session’s identifier is valid. If it’s not, processing will stop.
- If the identifier was valid, retrieve the
SessionCache
object. - The final confusing piece of background downloads is that you must tell the watchOS when the session has completed, so save the provided completion handler.
Of course, sessionCompletion
doesn’t yet exist, so add a final property to SessionData:
var sessionCompletion: (() -> Void)? = nil
You’ve simply created an optional delegate that takes no parameters and returns nothing. Now, what should you do with that property? That’s right, call it from downloadCompleted(for:data:)
in SessionCache, right after calling the other delegate method.
sessionData.sessionCompletion?()
sessionData.sessionCompletion = nil
Nothing to it, right? :]
At this point, after building and running the app, you’ll start to see fresh tide data every hour on your complications.
Key Points¶
- Always use a data download, not a data task.
- Ensure you call both the session event’s completion handler as well as the timeline provider’s completion handler.