Chapters

Hide chapters

watchOS With SwiftUI by Tutorials

First Edition · watchOS 8 · Swift 5.5 · Xcode 13.1

Section I: watchOS With SwiftUI

Section 1: 16 chapters
Show chapters Hide chapters

10. Keeping Complications Updated
Written by Scott Grosch

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

Your worker needs to be able to schedule jobs:

  1. If the app is just starting, you might need to schedule a first background job immediately. If it’s the first run, then start a minute from now, otherwise start 15 minutes later. Remember, you only get four updates an hour. So you need to wait at least 15 minutes for subsequent calls if you plan to spread the calls over the hour.
  2. Calendrical calculations should be familiar by now. You’re simply adding the number of minutes to the current time.
  3. Schedule the job to run at the desired time.

If you need data to be available to the job when watchOS launches it, use the userInfo parameter.

Add one more method to complete your background worker class:

public func perform(_ completion: (Bool) -> Void) {
  // Do your background work here
  completion(true)
}

When ExtensionDelegate is ready to run your scheduled job, it’ll call the perform(_:) method. Handle all the required work, then call the completion handler with true if the active complications should update with new values, otherwise false.

ExtensionDelegate background task

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

private let backgroundWorker = BackgroundWorker()

Then add the following code to the switch statement in the handle(_:) method:

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

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

Here, you:

  1. Check if the current task is of type WKApplicationRefreshBackgroundTask.
  2. Call the perform method and supply the completion handler to call when the work finishes.
  3. If you passed true to the completion handler, you tell the complications to update themselves.
  4. Finally, you schedule the next background task and mark the task as completed.

Notice how you mark the task as completed inside of the completion handler. handle(_:) will complete before your job finishes. If you mistakenly mark the task as complete outside of the completion handler, your job will never fully run because watchOS will terminate it.

Note: If your complications are updated, watchOS will schedule a snapshot automatically. Therefore, always pass false to task.setTaskCompletedWithSnapshot.

Depending on your app’s requirements, it may not make sense to automatically schedule the next task. The pattern above assumes that you call schedule(true) from somewhere like applicationDidFinishLaunching and need to repeat on a known time cycle.

While most frameworks are available to your app during a background task, the notable exception is URL downloads. If you try to perform a URL download from a background task, watchOS will hand you an error.

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.

Like background tasks, you can run up to four downloads per hour. Unlike background tasks, you can run all four at once if you wish. While the specific details are unclear, Apple warns that the actual number depends on factors such as Wi-Fi availability, cellular signal strength and battery life.

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

Lots of code, but nothing too confusing:

  1. You declare a class to handle URL downloads. It needs to implement URLSessionDownloadDelegate, so it has to subclass from NSObject.
  2. Background URLSession tasks are assigned identifiers, so you provide callers with a way to specify which identifier to use. Subclassing NSObject means you’ll need to provide an explicit initializer.
  3. URLSession should only be created once, on-demand.
  4. Background downloads must use the special URLSessionConfiguration and be provided with your desired identifier.
  5. By setting isDiscretionary to false, which is the default, you tell watchOS that it should try to run your download as soon as you ask it to, instead of letting it determine the best time.
  6. Setting sessionSendsLaunchEvents to true, the default, tells watchOS to automatically wake up or launch your app in the background when required.
  7. Finally, you create the URLSession object with your specified configuration. watchOS will create a serial operation queue to handle the delegate callbacks if you specify nil for the delegateQueue parameter.
  8. You’ll learn more about the delegate in just a bit, but you need to implement the protocol to keep Xcode from displaying errors.

Scheduling a network download

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

private var backgroundTask: URLSessionDownloadTask?

Then implement the scheduling method:

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

In the preceding code:

  1. The initial setup, including date calculation, is the same as for background tasks.

  2. You generate a download task using the backgroundSession you just configured.

  3. By setting earliestBeginDate, you let watchOS know that it shouldn’t start the network download before the indicated date. If the API you call uses caching headers, use those to help define the earliest beginning date.

  4. Telling watchOS exactly how many bytes you expect to send, including header count, and receive helps it optimize when to perform the download. Pay close attention to the property names! There are multiple properties with almost the same name, and it’s easy to get confused.

  5. If you forget to call resume, the network download won’t start.

  6. Finally, you store the task in your class’ property to use in delegate methods.

Note: Use the delegate pattern for background URL downloads. You may not use the newer async methods.

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

In the preceding code:

  1. The data provided by the API call is JSON, so you need a way to decode it.

  2. The location you provide should be a file URL. The check is probably not entirely necessary, but better safe than sorry. :]

  3. You read the contents of the file that watchOS wrote the data to.

  4. Then, you decode the data based on the Weather structure provided with the sample project.

  5. You grab the first temperature provided.

  6. Then, store the downloaded temperature to UserDefaults so that you can access it in the complication. The provided ComplicationController class displays the temperature stored in this location.

Keep in mind, when this delegate method ends, watchOS will automatically delete the file at location. If you download an image or movie, copy the file somewhere appropriate. If you downloaded JSON data, such as in this example, store the data as needed in something like Core Data, @AppStorage or UserDefaults.

Note: In this example, you grab whatever temperature is first, which isn’t really the current temperature. Book…sample…you get it.

Once the session has fully completed, watchOS will call the urlSession(_:task:didCompleteWithError:) delegate method. Add that method to your delegate implementation:

func urlSession(
  _ session: URLSession,
  task: URLSessionTask,
  didCompleteWithError error: Error?
) {
  backgroundTask = nil

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

Recall that the delegate methods run on a serial dispatch queue. When you call the completion handler, you need to dispatch that back to the main queue.

Xcode isn’t so happy right now about that whole completion handler thing. Fix that up now.

Preparing for download

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

private var completionHandler: ((Bool) -> Void)?

Then implement the perform method:

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

Err…uh…that looks pointless! You’ve stumbled upon the major confusion of background URL downloads.

Recall that your app may go in and out of background mode while the download occurs. When watchOS re-attaches your app, you have to let it know that it should reuse the previous session, so it calls the delegate methods properly.

By recreating a session with the exact same identifier that you originally used, watchOS ties everything together for you. Assigning to the _ variable discards the result because you don’t need to hold onto it but has the side effect of creating the session again if it doesn’t already exist.

ExtensionDelegate network download

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

private var downloads: [String: UrlDownloader] = [:]

Then add a helper method to manage the dictionary:

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

Here’s what the code is doing:

  1. You declare a method that returns a UrlDownloader for a given identifier.
  2. If the UrlDownloader for the given identifier doesn’t already exist, create a new one.
  3. If it does exist, directly return it.

Finally, add another case to the switch statement:

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

The code is quite similar to the WKApplicationRefreshBackgroundTask code:

  1. You verify that you received a WKURLSessionRefreshBackgroundTask.
  2. Using your helper method, you grab the appropriate UrlDownloader instance for the task’s sessionIdentifier.
  3. You call the perform method and pass in a completion handler to call when the download completes. Like before, you update your complications, schedule the next download and mark the task as complete.

Note: If your complications are updated, watchOS will automatically schedule a snapshot. Therefore, always pass false to task.setTaskCompletedWithSnapshot.

If your network download requires extra delegate events, such as authentication challenges, you’ll need to call the completion handler from the urlSessionDidFinishEvents(forBackgroundURLSession:) delegate method as well. However, you must not schedule a new download at that point because the download itself hasn’t happened yet.

Updating ContentView

Add a new property to ContentView.swift:

@State private var downloader = UrlDownloader(identifier: "ContentView")

Remember, you can use any name for the identifier parameter. It just has to stay consistent for the same type of downloads.

Then replace the entire body with:

Button {
  downloader.schedule(firstTime: true)
} label: {
  Text("Download")
}

Build and run the app. Once it launches, return to the home screen and add the Updates complication to your watch face. Tap the complication to launch the app, and then tap Download.

Press the Digital Crown to go back to the home screen and then drop your wrist. Around a minute from now, watchOS will perform the background download and update the complication on your watch face, showing a temperature.

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.

While background tasks and background URL downloads work well, they’re not always the best solution. watchOS may kill your app, or it may crash. I know, your apps are 100% bug-free, but that stuff your colleague writes…

If you control the server your app pulls data from, it may make more sense to implement complication updates via push notifications. Using PushKit, you can send up to 50 updates per day to your Apple Watch.

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

PushKit setup is pretty straightforward:

  1. You’ll conform to a delegate protocol in a moment, so you must subclass NSObject.
  2. Initialize PushKit and specify that the delegate methods should be called on the main UI thread.
  3. Assign this class as the delegate.
  4. Specifying the desiredPushTypes lets PushKit know you’re sending complication updates.

Now start implementing the delegate. Add the following code to the end of the file, outside of the PushNotificationProvider class:

// 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.
  }
}

Implementing the delegate starts with these steps:

  1. Your class needs to conform to PKPushRegistryDelegate.
  2. watchOS calls pushRegistry(_:didUpdate:for:) when your app successfully registers with PushKit.
  3. Convert the unusable Data token to a string that can be sent to your server.

PushKit suffers from the same usability annoyance as standard push notifications: watchOS gives you a Data token instead of a usable string.

Once you decode the token, you’ll send it to your server. Be sure you somehow specify, on your server, that the token is for PushKit, not a normal push notification.

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

In the preceding code:

  1. Notice that the method signature includes the async keyword. Having this method asynchronous makes life easier as the updates you perform are likely asynchronous.
  2. The payload sent with the push notification is available in the payload’s dictionaryPayload property.
  3. Once you take appropriate action based on the incoming payload, tell your complications to update.

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.

If you’re able to register your app for PushKit notifications, you can test it the same way you would do with an iPhone. Make sure you have your app notifications activated on the Apple Watch. It is recommended to use a real device for testing it. There are some interesting frameworks which allow sending notifications to the simulator. However, it is out of the scope of this book explaining how to set them up.

Initialize the provider

In UpdatesApp.swift, add the following property:

private let push = PushNotificationProvider()

Initializing the object during app creation ensures that the provider is created and

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.
© 2026 Kodeco Inc.