跳转至

5.Snapshots

Written by Scott Grosch

The Dock on the Apple Watch lets the wearer see either the recently run apps or their favorite apps. Since watchOS already displays your app at the appropriate time, why should you care about the Dock?

While watchOS will insert an image of your app into the Dock, it’ll only display whatever the current screen contains, which may not be the best user experience.

In this chapter, you’ll build UHL, the official app for the Underwater Hockey League. You may not have heard of this sport before, and you’re not alone. Underwater hockey has a cult following. Two teams maneuver a puck across the bottom of a swimming pool.

You’re probably feeling the urge to dive in, so time to get to it. CANNONBALL!

img

Getting Started

Open UHL.xcodeproj from the starter folder. Build and run the UHL Watch App scheme.

img

With the UHL app, you can track the best team in the league: Octopi. Take a moment to explore the app. You’ll see Octopi’s record as well as the details of what matches are coming up next.

The Dock

The Apple Watch has only two physical buttons. While the Digital Crown is the most visible, there’s also a Side button right below the crown. Most users interact with the Side button by quickly pressing it twice to bring up Apple Pay.

If you press the button a single time, the Dock will launch. The Dock displays one of two sets of apps:

  1. Recently run
  2. Favorites

By default, recently run apps will display in the Dock:

img

Note

If you choose favorites, then you’ll only see apps that you’ve identified. The most recently run app no longer displays in the Dock. To choose what appears, you have to open the Watch app on your iPhone, tap My Watch, then tap Dock and select either Recents or Favorites.

The Dock provides multiple benefits:

  • Tapping an image launches the app immediately.
  • Quickly switching between apps.
  • Showing current app status at a glance.
  • App organization.

Apps currently in the Dock have an almost immediate launch time, as they’re kept in memory. In this chapter, your focus is the third bullet. Apps in the Dock show a screenshot of the app’s current state called a snapshot.

To see an example of snapshots in action, grab your Apple Watch and start a timer. You’ll need a physical device as the simulator doesn’t provide timers.

Once the timer is running, press the Side button and scroll to the Timer. It appears to be counting down in the Dock, but in reality, that’s not what’s happening.

The Dock is nothing more than a static image of the current state of the app. There are no interactive controls in the Dock. If you tap the snapshot, the app will launch. The Timer app appears to be updating as the app has configured itself to take a new snapshot every second.

Snapshot API

By default, when the Dock appears, the user sees what each app looked like before moving to the background. For most apps, nothing more is necessary. Sometimes, such as in the case of the Timer app, a single snapshot is not enough.

You, as the developer, are responsible for telling watchOS if it needs to perform extra snapshots once the app moves to the background. Before diving into the code, you should keep some rules of thumb in mind.

Snapshot Tips

Next, we are going to learn some tips and tricks you should take into account if you want to optimize your app snapshots.

Optimizing for Miniaturization

The snapshot is a scaled-down image of your app’s full-size dimensions. Be sure to carefully look at all the screens which you capture in a snapshot. Is the text still readable? Do images make sense at a smaller size?

Remember:

  • At smaller sizes, bolder fonts are easier to read. You may wish to use bolded fonts or larger font sizes for important information.
  • You may also need to consider removing less important elements from the screen.

Customizing the Interface

Recall that the snapshot is just an image of the app’s current display. With that in mind, you could make a custom View for when watchOS takes a snapshot and completely redesign the UI. Don’t do that.

Having a custom view could make sense in some situations, especially if you need to remove some elements from the snapshot. If you make a custom view, you want to ensure that the snapshot doesn’t look radically different from the normal display. People expect the snapshot to represent your app. If the snapshot looks too different, it becomes hard to recognize and find the app they’re looking for.

If you determine that you do need a different visual, please keep these points in mind:

  1. Focus on important information.
  2. Hide objects that aren’t as relevant when viewed in the Dock.
  3. Exaggerate the size of certain objects for legibility.
  4. Don’t make the interface look radically different.

Progress and Status

Progress screens, timers and general status updates are great use cases for snapshots. You may think that complications cover all of those cases. Hopefully, you created said complications! However, you need to remember that your customers may not want to use your complication.

During the COVID-19 pandemic, food delivery services became a major business. Online ordering apps are a perfect example of custom snapshots.

When you order the food, you see one view. Once the restaurant receives your order, the screen could change to show how long until the food is ready for pickup. When the driver gets the food, the screen could show how long until delivery.

During state changes, be sure that you’re not snapshotting errors or confirmation dialogs. Such issues are better handled via a local notification.

Changing Screens

Keep the currently active view the same as when the user last interacted with your app whenever possible. If the app’s state changes in unpredictable ways, it can confuse and disorient the user. You want the interaction between the Dock and your app to be so seamless that your customers don’t understand anything special is happening. If you need to leave the app in a different state, ensure that the end-user can quickly determine what happened.

Note

In the interests of keeping the sample app less complicated, UHL blatantly violates this tip. :]

Anticipating a Timeline

The inverse to the previous tip is that you should anticipate what the user would want to see when they look in the Dock.

Similar to the food delivery example, consider a sporting event. Depending on the time of the event, you might want to have something like this:

  • Shortly before the game, users want to see the time and location.
  • During the game, users want to see the current score.
  • After the game, users want to see the final score and your team’s record.
  • Other times, users likely want to see the season’s schedule.

User Preferences

Not every user is going to want to see the same thing. Can your app offer the possibility of customizing views even further?

A fair-weather fan who only cares about a single team will need a different experience than a sports fanatic who wants to follow the entire league. The fair-weather fan would probably expect the last game’s score to stick around in a snapshot much longer than the fanatic who constantly keeps up-to-date.

When Snapshots Happen

watchOS automatically schedules snapshots to update on your behalf in many different scenarios:

  • When the Apple Watch boots up.
  • When a complication update occurs.
  • When the app transitions from the foreground to the background.
  • When the user views a long look notification.
  • One hour after the user last interacted with the app.
  • At least once an hour for apps in the Dock. The Dock takes one snapshot every six minutes rotating through each app in sequence. If the user has fewer than ten apps in the Dock, each app will receive more frequent snapshot tasks than one per hour.
  • At optional, scheduled times, with the Background Refresh API.

Working With Snapshots

Now that you better understand how to use snapshots, it’s time to implement what you’ve learned in the UHL app!

Note

The simulator frequently gets confused while working with snapshots and the Dock. If that happens, quit and restart the simulator or preferably use a physical device instead.

Snapshots Handler

First, you need to implement the method watchOS calls when it’s time to take snapshot. Open ExtensionDelegate and add the following method:

func handle(_ backgroundTasks: Set<WKRefreshBackgroundTask>) {
  // 1
  backgroundTasks.forEach { task in
    // 2
    guard let snapshot = task as? WKSnapshotRefreshBackgroundTask else {
      task.setTaskCompletedWithSnapshot(false)
      return
    }

    // 3
    print("Taking a snapshot")

    // 4
    task.setTaskCompletedWithSnapshot(true)
  }
}

Whenever watchOS wakes up your app from the background, it calls handle(_:). Here’s what the preceding code accomplishes:

  1. First, it schedules one or more tasks for you to handle. Loop through each one.
  2. You only care about snapshots, so mark the task completed if it’s not a snapshot. Passing false tells the system not to auto-schedule another snapshot.
  3. I’ll bet this statement confuses you! :]
  4. Finally, you mark the snapshot task as complete and specify true so that watchOS automatically schedules another snapshot for an hour from now.

When watchOS calls handle(_:), you have a limited amount of time, on the order of seconds, to finish the task. If you neglect to mark the task as having completed, the system continues to run it in the background. Your task will then run until all available time has been consumed, which wastes battery power.

In step two, where you pass false to setTaskCompletedWithSnapshot, you might be wondering why you don’t specify true instead. Since you took no action on the task, there’s no reason to force an immediate snapshot to happen.

Bring up Xcode’s Debug area by pressing Shift-Command-C. Then build and run your app.

Once the simulator is running your app, switch to the Home screen. Be patient — after an indeterminate, but likely short, amount of time, you’ll see the message that your app took a snapshot.

Forcing a Snapshot

When you switched to the Home screen, the snapshot task didn’t immediately run because watchOS was busy performing other tasks. That might be OK for a normal production deployment, but when building the app, you’ll want to be able to tell the simulator to take a snapshot right now.

Remember that snapshots will only occur if the app is not in the foreground. The simulator — or physical device — must be showing any other app or the clock face. Of course, the app must be running via Xcode for you to see the fruits of your labor.

Once your app is running in the background, in Xcode’s menu bar, choose DebugSimulate UI Snapshot.

You might think you’d use the simulator to force a snapshot in it. Unfortunately, you’d be wrong. It’s easy to get confused and wonder why you can’t find the Simulate UI Snapshotmenu bar item.

You’ll see the message in the Debug area again, letting you know watchOS took a snapshot. Notice how nothing else on the watch face changes. Snapshots are a background task, meaning there’s no reason for watchOS to bring your app to the user’s attention.

Viewing a Snapshot

You can see what your snapshot looks like by visiting the Dock. In the simulator, you get to the Dock in three separate ways:

  1. Click the Side button in the simulated Apple Watch display.
  2. Click DeviceSide Button in the simulator’s menu bar.
  3. Press Shift-Command-B on your keyboard. Scholars have pondered for ages why it’s not Shift-Command-D. :]

After bringing up the dock, you’ll see that your app is the first on the list:

img

That doesn’t give you a great view of your snapshot, though. Run any other app, and then jump back to the dock. Now, you’ll have a better view of your snapshot:

img

Customizing the Snapshot

As previously discussed, your app’s snapshot defaults to the last screen which was visible. That’s not helpful for Octopi fans.

If the last match played occurred yesterday or today, you should show them the score of that match. If, instead, the next match will occur today or tomorrow, show that match’s schedule. Otherwise, take a new snapshot two days later.

Add the following method to ExtensionDelegate:

private func nextSnapshotDate() -> Date {
  // 1
  guard let nextMatch = Season.shared.nextMatch else {
    return .distantFuture
  }

  // 2
  let twoDaysLater = Calendar.current.date(
    byAdding: .day,
    value: 2,
    to: nextMatch.date
  )!

  // 3
  return Calendar.current.startOfDay(for: twoDaysLater)
}

The preceding code accomplishes these three tasks:

  1. If there’s no upcoming match, you return .distantFuture for the date. Using .distantFuture essentially tells watchOS that there are no more scheduled snapshots.
  2. Using calendrical calculations, you determine what the date will be in two days. This calculation can’t fail, so it’s safe to force unwrap the return value.
  3. Finally, you determine the start of the day for when the snapshot will happen.

Snapshot User Info

The Snapshot API is based around a user info type dictionary. While you could use a dictionary, there’s a better way to handle the data. You don’t want to have to hardcode strings for the keys or define global let type constants. Instead, create a new Swift file named SnapshotUserInfo.

When a snapshot happens, you have to switch the app to the appropriate screen depending on the rules previously defined. The sample app already has much of the code implemented to handle that task for you. The goal here is to learn about snapshots, not the craziness that is controlling which SwiftUI view gets automatically pushed onto the navigation stack.

Start with:

import Foundation

struct SnapshotUserInfo {
  // 1
  let handler: () -> Void
  // 2
  let destination: ContentView.Destination
  // 3
  let matchId: Match.ID?
}

SnapshotUserInfo implemented this so far:

  1. You have to tell the snapshot when it’s completed. More on that in a moment.
  2. ContentView.Destination is an enum identifying which view will be pushed onto the navigation stack.
  3. You need to identify a match to snapshot.

Look in Match located in Model group. You’ll see that the Identifiable protocol is implemented, and the type of the identifier is a UUID. While you could use a UUID type throughout the app, it’s a better idea to reference Match.ID instead. Why? If at a later date you realize you need the identifier to be an Int instead, there’s only a single location you need to update.

Next, in SnapshotUserInfo, implement an initializer:

init(
  handler: @escaping () -> Void,
  destination: ContentView.Destination,
  matchId: Match.ID? = nil
) {
  self.handler = handler
  self.destination = destination
  self.matchId = matchId
}

While not technically required, implementing the initializer lets you initialize the structure instance without having to explicitly specify nil for the match.

Now you need a way to generate the dictionary that the Snapshot API wants. Add:

// 1
private enum Keys: String {
  case handler, destination, matchId
}

// 2
func encode() -> [AnyHashable: Any] {
  return [
    Keys.handler.rawValue: handler,
    Keys.destination.rawValue: destination,
    // 3
    Keys.matchId.rawValue: matchId as Any
  ]
}

Here’s a code breakdown:

  1. You define an enum to store the keys to the dictionary. You could also use let strings, but the enum is cleaner for this use case.
  2. You create a method that encodes the struct into the type of dictionary the Snapshot API requires.
  3. The match identifier could be nil, so you must explicitly add the as Any cast to make the compiler happy.

You’ll also need a way to convert from the API’s dictionary into the object you defined. Add a custom error type right outside the struct:

enum SnapshotError: Error {
  case noHandler, badDestination, badMatchId, noUserInfo
}

Then inside the struct, implement the method to convert from the API’s dictionary information:

// 1
static func from(notification: Notification) throws -> Self {
  // 2
  guard let userInfo = notification.userInfo else {
    throw SnapshotError.noUserInfo
  }

  guard let handler = userInfo[Keys.handler.rawValue] as? () -> Void else {
    throw SnapshotError.noHandler
  }

  guard
    let destination = userInfo[Keys.destination.rawValue] as? ContentView.Destination
  else {
    throw SnapshotError.badDestination
  }

  // 3
  return .init(
    handler: handler,
    destination: destination,
    // 4
    matchId: userInfo[Keys.matchId.rawValue] as? Match.ID
  )
}

Here’s what’s happening:

  1. Using a static method allows for a factory pattern type of creation.
  2. You pull the individual pieces out of the posted Notification, throwing an appropriate error if anything goes wrong.
  3. Then, you create and return a properly initialized object.
  4. Similarly to the other method, using an as? Match.ID cast handles the case when no match identifier is provided.

Viewing the Last Game’s Score

Sometimes, even the best of fans will miss a game. If the most recent match was yesterday or today, wouldn’t it be great if you showed the score?

In Season, inside the Model group, find the following line of code:

bySettingHour: Int.random(in: 18 ... 20),

Update the values to be something earlier in the day than the current hour. You want to make it appear like a match already happened today.

Add the following method to the ExtensionDelegate class:

private func lastMatchPlayedRecently() -> Bool {
  // 1
  guard let last = Season.shared.pastMatches().last?.date else {
    return false
  }

  // 2
  return Calendar.current.isDateInYesterday(last) ||
         Calendar.current.isDateInToday(last)
}

Here’s what’s happening:

  1. If there’s a previously played match, grab the date. If not, simply return false.
  2. Determine if the last match occurred yesterday or today.

In the handle(_:) method, delete the final line which completes the snapshot and replace it with:

let nextSnapshotDate = nextSnapshotDate()

let handler = {
  snapshot.setTaskCompleted(
    restoredDefaultState: false,
    estimatedSnapshotExpiration: nextSnapshotDate,
    userInfo: nil
  )
}

setTaskCompletedWithSnapshot(_:) is a convenience method for the longer setTaskCompleted(restoredDefaultState:estimatedSnapshotExpiration:userInfo). Try saying that one three times fast!

  • When watchOS takes the snapshot, you tell it to complete the task. You pass falseto the first parameter because you left the app in a different state than it was originally in.
  • The second parameter, estimatedSnapshotExpiration, tells watchOS when the current snapshot is no longer valid. Essentially, the date you provide is when it needs to take a new snapshot.
  • Finally, for the userInfo parameter, simply pass nil as you don’t need the next snapshot to know anything about the app’s current state. If you did, userInfo is where you could pass something along.

Notice how you’ve assigned that completion call to a handler variable. You don’t want to complete the task until you’re ready for watchOS to take the snapshot, which means you first have to get the views pushed onto the navigation stack. That’s why your SnapshotUserInfo has a handler property.

Continue adding to handle(_:):

// 1
var snapshotUserInfo: SnapshotUserInfo?

// 2
if lastMatchPlayedRecently() {
  snapshotUserInfo = SnapshotUserInfo(
    handler: handler,
    destination: .record
  )
}

// 3
if let snapshotUserInfo = snapshotUserInfo {
  NotificationCenter.default.post(
    name: .pushViewForSnapshot,
    object: nil,
    userInfo: snapshotUserInfo.encode()
  )
} else {
  // 4
  handler()
}

Lots going on with that code:

  1. You create a variable of type SnapshotUserInfo, which is currently not set.
  2. If there’s been a recent match, you populate that variable with the handler you just defined and specify that the record view is what should display in the snapshot.
  3. If there’s data for the snapshotUserInfo, you post a notification with that object. Notice how the userInfo of the notification is the encoded object.
  4. If not, then you simply call the handler to complete the task.

The sample project includes a file called Notification.Name+Extension which defines the notification type for you.

Putting the details into a struct instead of just populating the userInfo dictionary directly makes the code much cleaner. By abstracting away the data’s details, it also becomes much easier to test.

Phew! That’s quite a bit of code. Press Command-B to do a quick build of your project to make sure you haven’t missed anything. It’ll compile cleanly with neither warnings nor errors at this point.

When the system wants to take a snapshot, and there’s a recently played match, you now post a notification with the details. When the notification happens, the app needs to navigate to RecordView.

Edit RecordView, located in Record, then add a new property to the RecordViewstructure:

let snapshotHandler: (() -> Void)?

Then update the PreviewProvider appropriately:

RecordView(snapshotHandler: nil)

Remember that the handler you created in ExtensionDelegate needs to be called when the snapshot is ready to be taken.

When RecordView is the active view, then, and only then, can you complete the background task. watchOS 8 added a wonderful new View method called task, which is perfect for your needs. The code in the task block will run one time when the view appears and will cancel when the view goes away.

Add a call to the snapshot handler to the List view:

var body: some View {
  List(season.pastMatches().reversed()) {
    ...
  }
  ...
  .task {
    snapshotHandler?()
  }
}

Once the view appears, if snapshotHandler has a value, the method will be called, appropriately signifying to the snapshot task that it is complete.

Finally, edit ContentView and add two new properties:

// 1
@State private var snapshotHandler: (() -> Void)?

// 2
private let pushViewForSnapshotPublisher = NotificationCenter
  .default
  .publisher(for: .pushViewForSnapshot)

For those two properties:

  1. Since ContentView is the top of your navigation stack, the property you store the snapshot handler in has to be @State as this is the view that will assign the value based on the notification.
  2. Taking advantage of Combine makes responding to notifications quite simple in SwiftUI.

Add a function to call the snapshot handler and then clear it out so it doesn’t get called a second time.

private func handleSnapshot() {
  snapshotHandler?()
  snapshotHandler = nil
}

Now you can fix the compiler error by passing the function call to the RecordViewinitializer:

RecordView(snapshotHandler: handleSnapshot)

Next, add a new method to handle when the notification is called:

private func pushViewForSnapshot(_ notification: Notification) {
  // 1
  guard let info = try? SnapshotUserInfo.from(notification: notification) else {
    return
  }

  // 2
  snapshotHandler = info.handler

  // 3
  path.append(info.destination)

  // 4
  if let matchId = info.matchId, let match = season.match(with: matchId) {
    path.append(.matchDetail(match: match))
  }
}

For pushViewForSnapshot(_:):

  1. You pull the SnapshotUserInfo details from the notification you posted in ExtensionDelegate.
  2. Extract the handler into a local variable for ease of use.
  3. Adding the destination to path is what tells the UI to switch to that particular view.
  4. If the info provides a specific match, add a detail view for that match to the navigation path.

Note

When the user taps on an app in the doc, the app will still have the state from the last snapshot. Adding more than one destination to the navigation path puts the navigation path in the same state it would be in if the user tapped through to the match detail view themselves.

All that’s left to do is “catch” the notification. After the second NavigationLink, before closing the VStack, call your helper method when a notification appears:

.onReceive(pushViewForSnapshotPublisher) {
  pushViewForSnapshot($0)
}

Note

There are multiple ways to catch and handle a notification in SwiftUI. Use whatever method feels most natural to you.

At this point, build and run the app. Perform the following steps to test all your hard work:

  1. Ensure you’ve navigated to the first screen of the app.
  2. Change to the Home screen.
  3. In Xcode’s menu bar, click DebugSimulate UI Snapshot.
  4. In the simulator, bring up the Dock.

You’ll now see that your app did move to the proper view before taking a snapshot.

img

Wow, that was a ton of code! Take a quick break if you need to, and then it’ll be time to handle upcoming matches.

Viewing Upcoming Matches

If there’s no recent match, there might be a pending one. Edit ExtensionDelegate and add another method:

private func idForPendingMatch() -> Match.ID? {
  guard let match = Season.shared.nextMatch else {
    return nil
  }

  let date = match.date
  let calendar = Calendar.current

  if calendar.isDateInTomorrow(date) || calendar.isDateInToday(date) {
    return match.id
  } else {
    return nil
  }
}

The code is pretty self-explanatory. If there’s a match today or tomorrow, return the match’s identifier. Otherwise, return nil.

In the handle(_:) method, find the if, where you check for a recently played match:

if lastMatchPlayedRecently() {
  snapshotUserInfo = SnapshotUserInfo(
    handler: handler,
    destination: .record
  )
}

And add else branch with print statements, so it’ll take the following form:

if lastMatchPlayedRecently() {
  print("Going to record")
  snapshotUserInfo = SnapshotUserInfo(
    handler: handler,
    destination: .record
  )
} else if let id = idForPendingMatch() {
  print("Going to schedule")
  snapshotUserInfo = SnapshotUserInfo(
    handler: handler,
    destination: .schedule,
    matchId: id
  )
}

When the notification happens, the app needs to navigate to ScheduleView and then ScheduleDetailView for the specified matchId. That’s a bit complicated in SwiftUI, but the code is already there for you. You just need to add in the snapshot specific details now.

Working backward, to help avoid compiler errors while you’re coding, first edit ScheduleDetailView. You’ll perform similar steps to what you did for the record view. Add a new property:

let snapshotHandler: (() -> Void)?

Then calls the snapshot handler in a .task:

VStack {
  ...
  VStack {
    ...
  }
}
.task {
  snapshotHandler?()
}

And update the PreviewProvider:

ScheduleDetailView(
  match: Season.shared.nextMatch!,
  snapshotHandler: nil
)

Make the same changes to ScheduleView, remembering all three steps:

  1. Add the snapshotHandler property.
  2. Add the .task to the end of the body.
  3. Update the PreviewProvider to pass in a nil value for the snapshotHandler.

In your best pirate voice, think, “Here be compiler errors.”

Back in ContentView, inside the navigationDestination(for:) modifier, update the call to include the snapshot provider:

.navigationDestination(for: Destination.self) { destination in
  switch destination {
  case .schedule:
    ScheduleView(snapshotHandler: handleSnapshot)
  case .record:
    RecordView(snapshotHandler: handleSnapshot)
  case .matchDetail(let match):
    ScheduleDetailView(match: match, snapshotHandler: handleSnapshot)
  }
}

Build and run the app. This time, tap the first button to navigate to the upcoming matches view. Swipe left on a row, and you’ll see a button to add a match as well as delete the current match:

img

Tap the + button to create a random match that occurs today. Also, the most recent match which occurred earlier today or yesterday is removed to ensure that the record view doesn’t appear.

Force another snapshot as you did for the record view:

  1. Ensure you’ve navigated to the first screen of the app.
  2. Change to the Home screen.
  3. In Xcode’s menu bar, click DebugSimulate UI Snapshot.
  4. In the simulator, bring up the Dock.

You’ll see that you now display the schedule view in the Dock:

img

Key Points

  • Make sure you always mark background tasks as completed as soon as possible. If you don’t, you’ll waste the user’s battery.
  • Snapshots are smaller than your app. Consider bolding text, removing images or making other minor changes to increase the information displayed.

Where to Go From Here?

The chapter’s sample code includes a ModifiedRecordView.swift file (in the same directory as starter and final), which shows an example of how you might detect that a snapshot is about to happen so that you can present a different view entirely if that makes sense for your app.

Now you’re a Dock connoisseur, and you’ve gained a deep understanding of how watchOS uses snapshots. You also learned how to modify snapshots based on contextual relevance.

If you’d like to take this further, try tweaking the size or color of objects in the snapshot. Remember that the snapshot is miniaturized, so you might want to make the text more readable.