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

5. Snapshots
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

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!

Getting started

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

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.

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.

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?

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.

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.

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.

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.

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?

When snapshots happen

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

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!

Snapshots handler

First, you need to implement the method watchOS called when it’s time to take snapshot. Open ExtensionDelegate.swift 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)
  }
}

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.

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:

Customizing the app name

Most of the time, the display name of your WatchKit app doesn’t matter: Icons on the Home screen don’t display a label like they do in iOS.

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.

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

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.swift.

import Foundation

struct SnapshotUserInfo {
  // 1
  let handler: () -> Void
  // 2
  let destination: ContentView.Destination
  // 3
  let matchId: Match.ID?
}
init(
  handler: @escaping () -> Void,
  destination: ContentView.Destination,
  matchId: Match.ID? = nil
) {
  self.handler = handler
  self.destination = destination
  self.matchId = matchId
}
// 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
  ]
}
enum SnapshotError: Error {
  case noHandler, badDestination, badMatchId, noUserInfo
}
// 1
static func from(notification: Notification) throws -> Self {
  // 2
  guard let userInfo = notification.userInfo else {
    throw SnapshotError.noHandler
  }

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

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?

bySettingHour: Int.random(in: 18 ... 20),
private func lastMatchPlayedRecently() -> Bool {
  // 1
  guard let last = Season.shared.pastMatches().last?.date else {
  	 print("No last date")
    return false
  }

  print("The date is \(last.formatted()) and now is \(Date.now.formatted())")

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

let handler = {
  snapshot.setTaskCompleted(
    restoredDefaultState: false,
    estimatedSnapshotExpiration: nextSnapshotDate,
    userInfo: nil
  )
}
// 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()
}
let snapshotHandler: (() -> Void)?
RecordView(snapshotHandler: nil)
var body: some View {
  List(season.pastMatches().reversed()) {
    ...
  }
  ...
  .task {
    snapshotHandler?()
  }
}
// 1
@State private var snapshotHandler: (() -> Void)?

// 2
private let pushViewForSnapshotPublisher = NotificationCenter
  .default
  .publisher(for: .pushViewForSnapshot)
NavigationLink(
  destination: RecordView(),
NavigationLink(
  destination: RecordView(snapshotHandler: snapshotHandler),
private func pushViewForSnapshot(_ notification: Notification) {
  // 1
  guard
    let info = try? SnapshotUserInfo.from(notification: notification)
  else {
    return
  }

  // 2
  snapshotHandler = info.handler
  selectedMatchId = info.matchId

  // 3
  activeDestination = info.destination
}
.onReceive(pushViewForSnapshotPublisher) {
  pushViewForSnapshot($0)
}

Viewing upcoming matches

If there’s no recent match, there might be a pending one. Edit ExtensionDelegate.swift 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
  }
}
if lastMatchPlayedRecently() {
  snapshotUserInfo = SnapshotUserInfo(
    handler: handler,
    destination: .record
  )
}
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
  )
}
let snapshotHandler: (() -> Void)?
VStack {
  ...
  VStack {
    ...
  }
}
.task {
  snapshotHandler?()
}
ScheduleDetailView(
  match: Season.shared.nextMatch!,
  snapshotHandler: nil
)
let snapshotHandler: (() -> Void)?
destination: ScheduleDetailView(
  match: match
),
destination: ScheduleDetailView(
  match: match,
  snapshotHandler: snapshotHandler
),
ScheduleView(selectedMatchId: .constant(nil), snapshotHandler: nil)
destination: ScheduleView(
  selectedMatchId: $selectedMatchId,
  snapshotHandler: snapshotHandler
),

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, 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.

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