Chapters

Hide chapters

watchOS With SwiftUI by Tutorials

Second Edition · watchOS 9 · Swift 5.8 · Xcode 14.3

Section I: watchOS With SwiftUI

Section 1: 13 chapters
Show chapters Hide chapters

9. Keeping Complications Updated
Written by Scott Grosch

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

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.

private let rootUrl = "https://api.tidesandcurrents.noaa.gov/api/prod/datagetter"
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) }
}
components.queryItems = queryItems(for: stationId, from: start, to: end)
var components = URLComponents(string: rootUrl)
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()
}
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)
  }
}
let levels = decodeTide(data)

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.

import Foundation

final class SessionData {
  let session: URLSession

  init(session: URLSession) {
    self.session = session
  }
}
import Foundation

// 1
final class SessionCache: NSObject {
  // 2
  static let shared = SessionCache()

  // 3
  var sessions: [String: SessionData] = [:]

  // 4
  private override init() {}
}
// 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
}
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)
var downloadCompletion: (([Tide]) -> Void)? = nil
// 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)))
}
AccessoryRectangularView(
  tide: entry.tide,
  stationName: entry.configuration.station?.displayString
)

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
  }
}
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)
}

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.

func isValid(for stationId: MeasurementStation.ID) -> Bool {
  return sessions[stationId] != nil
}
.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
}
var sessionCompletion: (() -> Void)? = nil
sessionData.sessionCompletion?()
sessionData.sessionCompletion = nil

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.
Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You’re accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now