Home iOS & Swift Books watchOS With SwiftUI by Tutorials

10
Keeping Complications Updated Written by Scott Grosch

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

You can unlock the rest of this book, and our entire catalogue of books and videos, with a raywenderlich.com Professional subscription.

Now that your complications are available to place on the watch face, you need to address one last consideration. How do you ensure that the displayed data is up to date?

You learned how to reload the timeline when new data becomes available. Now it’s time to learn different ways to retrieve that data. There are four options available to you:

  • Update based on changes while the app is active.
  • Schedule background tasks to make changes.
  • Schedule background URLSession downloads.
  • Send notifications via PushKit.

Since you’ve already learned how to reload a timeline, this chapter will address the latter three techniques.

Scheduled background tasks

There will be times when you know an update should take place in the future, but the watch likely won’t be running your app during that time. Calling scheduleBackgroundRefresh(withPreferredDate:userInfo:scheduledCompletion:) from WKExtension lets you specify a future date when watchOS should wake your app up in the background and perform work.

When watchOS starts the background task, your app gets four seconds of CPU time and 15 seconds of total time to complete the task. While you’re allowed to schedule up to four background tasks per hour, you may only have one scheduled at any given time. If you schedule a second task while one is already scheduled, the previous task will cancel automatically.

Open ExtensionDelegate.swift from this chapter’s starter materials. When watchOS launches your app to perform a background task, it calls the handle(_:) method from WKExtensionDelegate.

Add the following method to ExtensionDelegate:

func handle(_ backgroundTasks: Set<WKRefreshBackgroundTask>) {
  // 1
  backgroundTasks.forEach { task in
    // 2
    switch task {
    default:
      // 3
      task.setTaskCompletedWithSnapshot(false)
    }
  }
}

Here’s what’s happening:

  1. watchOS provides you with one or more tasks, so you must iterate through each task.
  2. WKRefreshBackgroundTask is a base class, which you’ll need to examine to determine the specific subclass. You’ll implement that check in a moment.
  3. If the task type provided isn’t one you care about, mark the task as completed. Pass false so that a new snapshot isn’t scheduled since you haven’t performed any changes.

Refer back to Chapter 5, “Snapshots”, if you need a refresher.

There are four steps involved when a background task launches:

  1. Perform the necessary work to complete the task.
  2. Update your complications if something has changed based on the task.
  3. Schedule the next background task, if required.
  4. Mark the task as completed.

Note: Pay special attention to the fact that you need to schedule the next background task before marking the current task as complete. watchOS will stop providing cycles to your app once you specify the task is done.

The background worker

Create a new file named BackgroundWorker.swift and add:

import Foundation
import WatchKit

final class BackgroundWorker {
  // 1
  public func schedule(firstTime: Bool = false) {
    let minutes = firstTime ? 1 : 15

    // 2
    let when = Calendar.current.date(
      byAdding: .minute,
      value: minutes,
      to: Date.now
    )!

    // 3
    WKExtension
      .shared()
      .scheduleBackgroundRefresh(
        withPreferredDate: when,
        userInfo: nil
      ) { error in
        if let error = error {
          print("Unable to schedule: \(error.localizedDescription)")
        }
      }
  }
}
public func perform(_ completion: (Bool) -> Void) {
  // Do your background work here
  completion(true)
}

ExtensionDelegate background task

Switch back to ExtensionDelegate.swift and add a new property to ExtensionDelegate:

private let backgroundWorker = BackgroundWorker()
// 1
case let task as WKApplicationRefreshBackgroundTask:  
  // 2
  backgroundWorker.perform { updateComplications in
    // 3
    if updateComplications {
      Self.updateActiveComplications()
    }

    // 4
    backgroundWorker.schedule()
    task.setTaskCompletedWithSnapshot(false)
  }

Background URL downloads

Downloading data from the network follows the same general pattern as background tasks. However, they’re a bit trickier as network downloads require delegates, and there could be more than one running at a time.

URLSession setup and configuration

Create a file named UrlDownloader.swift and add:

import Foundation

// 1
final class UrlDownloader: NSObject {
  // 2
  let identifier: String

  init(identifier: String) {
    self.identifier = identifier
  }

  // 3
  private lazy var backgroundUrlSession: URLSession = {
    // 4
    let config = URLSessionConfiguration.background(
      withIdentifier: identifier
    )

    // 5
    config.isDiscretionary = false

    // 6
    config.sessionSendsLaunchEvents = true

    // 7
    return .init(
      configuration: config,
      delegate: self,
      delegateQueue: nil
    )
  }()
}

// 8
extension UrlDownloader: URLSessionDownloadDelegate {
  func urlSession(
    _ session: URLSession,
    downloadTask: URLSessionDownloadTask,
    didFinishDownloadingTo location: URL
  ) {
  }
}

Scheduling a network download

To schedule the download, add a new property to UrlDownloader:

private var backgroundTask: URLSessionDownloadTask?
// 1
func schedule(firstTime: Bool = false) {
  let minutes = firstTime ? 1 : 15

  let when = Calendar.current.date(
    byAdding: .minute,
    value: minutes,
    to: Date.now
  )!

  // 2
  let url = URL(
    string: "https://api.weather.gov/gridpoints/TOP/31,80/forecast"
  )!
  let task = backgroundUrlSession.downloadTask(with: url)

  // 3
  task.earliestBeginDate = when

  // 4
  task.countOfBytesClientExpectsToSend = 100
  task.countOfBytesClientExpectsToReceive = 12_000

  // 5
  task.resume()

  // 6
  backgroundTask = task
}

URLSessionDownloadDelegate

When the backgroundTask finishes downloading, watchOS will call the urlSession(_:downloadTask:didFinishDownloadingTo:) defined by URLSessionDownloadDelegate. Add the following code to that method:

// 1
let decoder = JSONDecoder()

guard
  // 2
  location.isFileURL,
  // 3
  let data = try? Data(contentsOf: location),
  // 4
  let decoded = try? decoder.decode(Weather.self, from: data),
  // 5
  let temperature = decoded.properties.periods.first?.temperature
else {
  return
}

// 6
UserDefaults.standard.set(temperature, forKey: "temperature")
func urlSession(
  _ session: URLSession,
  task: URLSessionTask,
  didCompleteWithError error: Error?
) {
  backgroundTask = nil

  DispatchQueue.main.async {
    self.completionHandler?(error == nil)
    self.completionHandler = nil
  }
}

Preparing for download

There’s one piece left to your UrlDownloader class. In UrlDownloader, add another property:

private var completionHandler: ((Bool) -> Void)?
public func perform(_ completionHandler: @escaping (Bool) -> Void) {
  self.completionHandler = completionHandler
  _ = backgroundUrlSession
}

ExtensionDelegate network download

Switch back to ExtensionDelegate.swift and add a property to track network downloads:

private var downloads: [String: UrlDownloader] = [:]
// 1
private func downloader(for identifier: String) -> UrlDownloader {
  // 2
  guard let download = downloads[identifier] else {
    let downloader = UrlDownloader(identifier: identifier)
    downloads[identifier] = downloader
    return downloader
  }

  // 3
  return download
}
// 1
case let task as WKURLSessionRefreshBackgroundTask:
  // 2
  let downloader = downloader(for: task.sessionIdentifier)

  // 3
  downloader.perform { updateComplications in
    if updateComplications {
      Self.updateActiveComplications()
    }

    downloader.schedule()
    task.setTaskCompletedWithSnapshot(false)
  }

Updating ContentView

Add a new property to ContentView.swift:

@State private var downloader = UrlDownloader(identifier: "ContentView")
Button {
  downloader.schedule(firstTime: true)
} label: {
  Text("Download")
}

Push notifications

Note: At the time of writing, watchOS has a bug — verified by Apple — that sometimes prevents your app from registering for PushKit notifications. Apple told me it believes it has determined the root cause. However, there’s no ETA on when the fix will be available.

PushKit registration

Complication push notifications are a bit different from standard remote push notifications. Create a new file named PushNotificationProvider.swift and add:

import Foundation
import PushKit

// 1
final class PushNotificationProvider: NSObject {
  // 2
  let registry = PKPushRegistry(queue: .main)

  override init() {
    super.init()

    // 3
    registry.delegate = self

    // 4
    registry.desiredPushTypes = [.complication]
  }
}
// 1
extension PushNotificationProvider: PKPushRegistryDelegate {
  // 2
  func pushRegistry(
    _ registry: PKPushRegistry,
    didUpdate pushCredentials: PKPushCredentials,
    for type: PKPushType
  ) {
    // 3
    let token = pushCredentials.token.reduce("") {
      $0 + String(format: "%02x", $1)
    }
    print(token)

    // Send token to your server.
  }
}

Receiving a push notification

To handle receiving a PushKit notification, implement the following delegate method:

// 1
func pushRegistry(
  _ registry: PKPushRegistry,
  didReceiveIncomingPushWith payload: PKPushPayload,
  for type: PKPushType
) async {
  // 2
  print(payload.dictionaryPayload)

  // 3
  await ExtensionDelegate.updateActiveComplications()
}

apns-topic

There’s one special consideration when sending a PushKit notification. The apns‑topic header should be the name of your extension’s bundle identifier with .complication appended to it. For example, when sending a notification to the sample app, you would set the apns‑topic to com.raywenderlich.Updates.watchkitapp.watchkitextension.complication.

Testing

Other than the extra text you need to add to the apns-topic, there’s nothing special about testing push notification. You can use the same server-side tools that you use for the rest of your app’s push notifications.

Initialize the provider

In UpdatesApp.swift, add the following property:

private let push = PushNotificationProvider()

Key points

  • Always schedule the next task before marking the current task as complete.
  • Call your completion handler from urlSessionDidFinishEvents(forBackgroundURLSession:) if using authentication, but do not schedule the next task at that point.
  • The WWDC 2020 session, Keep your complications up to date, says to create an app identifier with a .complication suffix. However, that guidance is no longer valid, as per Apple.
  • Append .complication to the extension’s bundle identifier for the apns‑topic header.

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.

© 2022 Razeware LLC

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a raywenderlich.com Professional subscription.

Unlock Now

To highlight or take notes, you’ll need to own this book in a subscription or purchased by itself.