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

4. Watch Connectivity
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.

The magic of the Apple Watch experience comes from seamless interactions between the watch and your iOS apps.

Note: This is the first chapter that requires the app to run on both the Apple Watch and iPhone at the same time. While this setup is possible by starting both simulators from Xcode, the connectivity mechanisms you’ll use in this chapter rarely work between them. You may need to run the example projects on real devices to see them in action. Even if you don’t have the hardware, it’s still a good read.

Watch Connectivity, an Apple-provided framework, lets an iOS app and its counterpart watchOS app transfer data and files back and forth. If both apps are active, communication occurs mostly in real time. Otherwise, communication happens in the background, so data is available as soon as the receiving app launches.

The OS takes many factors into account when determining exactly how quickly to pass data packets between devices. While the transfers frequently happen within a matter of moments, sometimes you’ll see a significant lag.

Be aware, to transfer data between devices, multiple system resources, such as Bluetooth, must be on. This can result in significant energy use.

Note: Bundle messages together whenever possible to limit battery consumption.

In this chapter, after learning about the different types of messaging options, you’ll implement data transfers between the iPhone and Apple Watch versions of CinemaTime, an app for patrons of a fictional theater. It lets customers view movie showtimes and buy tickets right from their iPhones and Apple Watches.

Device-to-Device Communication

The Watch Connectivity framework provides five different methods for transferring data between devices. Four of those methods send arbitrary data, while the fifth sends files between devices. All of the methods are part of WCSession.

Note: Although most data transfer methods accept a dictionary of type [String: Any], this doesn’t mean you can send just anything. The dictionary can only accept primitive types. See the Property List Programming Guide, for a complete list of supported types.

Those five methods are further subdivided into two categories: interactive messaging and background transfers.

Interactive Messaging

Interactive messaging works best in situations where you need to transfer information immediately. For example, if a watchOS app needs to trigger the iOS app to check the user’s current location, the interactive messaging API can transfer the request from the Apple Watch to the iPhone.

Reply Handlers

When sending an interactive message, you probably expect a reply from the peer device. You may pass a closure of type ([String: Any]) -> Void as the replyHandler, which will receive the message that the peer device sends back.

Error Handlers

When you wish to know when something goes wrong during a message transfer, you can use the errorHandler and pass a (Error) -> Void. For example, you’d call the handler if the network fails.

Background Transfers

If only one of the apps is active, it can still send data to its counterpart app using background transfer methods.

Guaranteed User Information

transferUserInfo(_:) makes the first type of background transfer. When calling this method, you specify the data is critical and must be delivered as soon as possible.

Application Context, aka High Priority User Information

High priority messages, delivered via updateApplicationContext(_:), are similar to guaranteed user information with two important differences:

Files

Sometimes you need to send actual files between devices, as opposed to just data. For example, the iPhone might download an image from the network and then send that image to the Apple Watch.

Getting Started

Open the CinemaTime starter project in Xcode. Then build and run the CinemaTime scheme. The simulator for the iPhone will appear.

Compare and Contrast

In the Apple Watch simulator, tap Purchase Tickets. Explore the app to see what you have to work with:

Place Your Order

Buy a movie ticket in either app. Then view the list of purchased movie tickets in the other app. Do you see the issue?

Setting Up Watch Connectivity

You should handle all connectivity between your devices from a single location in your code. Hopefully, the term singleton comes to mind!

import Foundation
// 1
import WatchConnectivity

final class Connectivity {
  // 2
  static let shared = Connectivity()

  // 3
  private init() {
    // 4
    #if !os(watchOS)
    guard WCSession.isSupported() else {
      return
    }
    #endif

    // 5
    WCSession.default.activate()
  }
}

Preparing for WCSessionDelegate

The WCSessionDelegate protocol extends NSObjectProtocol. That means for Connectivity to be the delegate, it must inherit from NSObject.

final class Connectivity: NSObject {
override private init() {
  super.init()

Implementing WCSessionDelegate

You need to make Connectivity conform to WCSessionDelegate. At the bottom of the file, add:

// MARK: - WCSessionDelegate
extension Connectivity: WCSessionDelegate {
  func session(
      _ session: WCSession,
      activationDidCompleteWith activationState: WCSessionActivationState,
      error: Error?
  ) {
  }

  func sessionDidBecomeInactive(_ session: WCSession) {
  }

  func sessionDidDeactivate(_ session: WCSession) {
  }
}
#if os(iOS)
func sessionDidBecomeInactive(_ session: WCSession) {
}

func sessionDidDeactivate(_ session: WCSession) {
}
#endif
// If the person has more than one watch, and they switch,
// reactivate their session on the new device.
WCSession.default.activate()
WCSession.default.delegate = self

Sending Messages

In the class, add:

public func send(movieIds: [Int]) {
  guard WCSession.default.activationState == .activated else {
    return
  }
}
// 1
#if os(watchOS)
// 2
guard WCSession.default.isCompanionAppInstalled else {
  return
}
#else
// 3
guard WCSession.default.isWatchAppInstalled else {
  return
}
#endif
// 1
let userInfo: [String: [Int]] = [
  ConnectivityUserInfoKey.purchased.rawValue: movieIds
]

// 2
WCSession.default.transferUserInfo(userInfo)

Receiving Messages

After transferring the user information, you need some way to receive it on the other device. You’ll receive the data in the WCSessionDelegate of session(_:didReceiveUserInfo:). Using the Combine framework is a great way to let your app know about the updates.

final class Connectivity: NSObject, ObservableObject {
  @Published var purchasedIds: [Int] = []
// 1
func session(
  _ session: WCSession,
  didReceiveUserInfo userInfo: [String: Any] = [:]
) {
  // 2
  let key = ConnectivityUserInfoKey.purchased.rawValue
  guard let ids = userInfo[key] as? [Int] else {
    return
  }

  // 3
  self.purchasedIds = ids
}

The Ticket Office

When you purchase or delete a ticket, you need to let companion devices know. Shared/TicketOffice handles purchasing and deleting tickets, so it seems like a good place to handle connectivity! Open that file.

private func updateCompanion() {
  // 1
  let ids = purchased.map { $0.id }

  // 2
  Connectivity.shared.send(movieIds: ids)
}
// 1
Connectivity.shared.$purchasedIds
  // 2
  .dropFirst()
  // 3
  .map { ids in movies.filter { ids.contains($0.id) }}
  // 4
  .receive(on: DispatchQueue.main)
  // 5
  .assign(to: \.purchased, on: self)
  //6
  .store(in: &cancellable)

Application Context

While functional, using transferUserInfo(:_) isn’t the best choice for CinemaTime. If you purchase a movie ticket on one device, you don’t need to see it immediately on the other device. Unless you’re standing right outside the theater, it’s OK if the transfer happens later. Even if you are outside the theater, you’d still use the device you purchased the ticket on.

enum Delivery {
  /// Deliver immediately. No retries on failure.
  case failable

  /// Deliver as soon as possible. Automatically retries on failure.
  /// All instances of the data will be transferred sequentially.
  case guaranteed

  /// High priority data like app settings. Only the most recent value is
  /// used. Any transfers of this type not yet delivered will be replaced
  /// with the new one.
  case highPriority
}
public func send(
  movieIds: [Int],
  delivery: Delivery,
  errorHandler: ((Error) -> Void)? = nil
) {
switch delivery {
case .failable:
  break

case .guaranteed:
  WCSession.default.transferUserInfo(userInfo)

case .highPriority:
  do {
    try WCSession.default.updateApplicationContext(userInfo)
  } catch {
    errorHandler?(error)
  }
}
private func update(from dictionary: [String: Any]) {
  let key = ConnectivityUserInfoKey.purchased.rawValue
  guard let ids = dictionary[key] as? [Int] else {
    return
  }

  self.purchasedIds = ids
}
func session(
  _ session: WCSession,
  didReceiveUserInfo userInfo: [String: Any] = [:]
) {
  update(from: userInfo)
}
func session(
  _ session: WCSession,
  didReceiveApplicationContext applicationContext: [String: Any]
) {
  update(from: applicationContext)
}
Connectivity.shared.send(movieIds: ids, delivery: .highPriority, errorHandler: {
  print($0.localizedDescription)
})

Optional Messages

Remember, interactive messages might fail to send. While that makes them inappropriate for the CinemaTime app, you’ll make the appropriate updates to Connectivity to support them so you can reuse the code later.

// 1
typealias OptionalHandler<T> = ((T) -> Void)?

// 2
private func optionalMainQueueDispatch<T>(handler: OptionalHandler<T>) -> OptionalHandler<T> {
  // 3
  guard let handler = handler else {
    return nil
  }

  // 4
  return { item in
    // 5
    Task { @MainActor in
      handler(item)
    }
  }
}

Non-Binary Data

Optional messages might or might not expect a reply from the peer device. So, add a new replyHandler to your send method in Shared/Connectivity so it looks like this:

public func send(
  movieIds: [Int],
  delivery: Delivery,
  replyHandler: (([String: Any]) -> Void)? = nil,
  errorHandler: ((Error) -> Void)? = nil
) {
WCSession.default.sendMessage(
  userInfo,
  replyHandler: optionalMainQueueDispatch(handler: replyHandler),
  errorHandler: optionalMainQueueDispatch(handler: errorHandler)
)
// This method is called when a message is sent with failable priority
// *and* a reply was requested.
func session(
  _ session: WCSession,
  didReceiveMessage message: [String: Any],
  replyHandler: @escaping ([String: Any]) -> Void
) {
  update(from: message)

  let key = ConnectivityUserInfoKey.verified.rawValue
  replyHandler([key: true])
}

// This method is called when a message is sent with failable priority
// and a reply was *not* requested.
func session(
  _ session: WCSession,
  didReceiveMessage message: [String: Any]
) {
  update(from: message)
}

Binary Data

Optional messages can also transfer binary data. It’s unclear why only optional messages provide a binary option.

private func canSendToPeer() -> Bool {
  guard WCSession.default.activationState == .activated else {
    return false
  }

  #if os(watchOS)
  guard WCSession.default.isCompanionAppInstalled else {
    return false
  }
  #else
  guard WCSession.default.isWatchAppInstalled else {
    return false
  }
  #endif

  return true
}
guard canSendToPeer() else { return }
public func send(
  data: Data,
  replyHandler: ((Data) -> Void)? = nil,
  errorHandler: ((Error) -> Void)? = nil
) {
  guard canSendToPeer() else { return }

  WCSession.default.sendMessageData(
    data,
    replyHandler: optionalMainQueueDispatch(handler: replyHandler),
    errorHandler: optionalMainQueueDispatch(handler: errorHandler)
  )
}
func session(
  _ session: WCSession,
  didReceiveMessageData messageData: Data
) {
}

func session(
  _ session: WCSession,
  didReceiveMessageData messageData: Data,
  replyHandler: @escaping (Data) -> Void
) {
}

Transferring Files

If you run the app on your iOS device and purchase a ticket, you’ll notice that the movie details include a QR code. Ostensibly, that QR code is what the theater would scan to grant entry. Purchasing a ticket on the Apple Watch, however, does not display a QR code.

QR Codes

Move CinemaTime/QRCode into Shared. Then add the watch app to the target membership. To fix the error that immediately appears, wrap both the import of CoreImage and generate(movie:size:) in a compiler check:

import SwiftUI
#if canImport(CoreImage)
import CoreImage.CIFilterBuiltins
#endif

enum QRCode {
  #if canImport(CoreImage)
  static func generate(movie: Movie, size: CGSize) -> UIImage? {
    // Code removed for brevity. [...]
  }
  #endif
}
#if os(watchOS)
static func url(for movieId: Int) -> URL {
  let documents = FileManager.default.urls(
    for: .documentDirectory,
    in: .userDomainMask
  )[0]

  return documents.appendingPathComponent("\(movieId).png")
}
#endif
public func send(
  movieIds: [Int],
  delivery: Delivery,
  wantedQrCodes: [Int]? = nil,
  replyHandler: (([String: Any]) -> Void)? = nil,
  errorHandler: ((Error) -> Void)? = nil
) {
var userInfo: [String: [Int]] = [
  ConnectivityUserInfoKey.purchased.rawValue: movieIds
]

if let wantedQrCodes {
  let key = ConnectivityUserInfoKey.qrCodes.rawValue
  userInfo[key] = wantedQrCodes
}
#if os(iOS)
public func sendQrCodes(_ data: [String: Any]) {
  // 1
  let key = ConnectivityUserInfoKey.qrCodes.rawValue
  guard let ids = data[key] as? [Int], !ids.isEmpty else { return }

  let tempDir = FileManager.default.temporaryDirectory

  // 2
  TicketOffice.shared
    .movies
    .filter { ids.contains($0.id) }
    .forEach { movie in
      // 3
      let image = QRCode.generate(
        movie: movie,
        size: .init(width: 100, height: 100)
      )

      // 4
      guard let data = image?.pngData() else { return }

      // 5
      let url = tempDir.appendingPathComponent(UUID().uuidString)
      guard let _ = try? data.write(to: url) else {
        return
      }

      // 6
      WCSession.default.transferFile(url, metadata: [key: movie.id])
    }
}
#endif
#if os(iOS)
sendQrCodes(dictionary)
#endif
// 1
#if os(watchOS)
func session(_ session: WCSession, didReceive file: WCSessionFile) {
  // 2
  let key = ConnectivityUserInfoKey.qrCodes.rawValue
  guard let id = file.metadata?[key] as? Int else {
    return
  }

  // 3
  let destination = QRCode.url(for: id)

  // 4
  try? FileManager.default.removeItem(at: destination)
  try? FileManager.default.moveItem(at: file.fileURL, to: destination)
}
#endif
#if os(watchOS)
offsets
  .map { purchased[$0].id }
  .forEach { id in
    let url = QRCode.url(for: id)
    try? FileManager.default.removeItem(at: url)
  }
#endif
// 1
var wantedQrCodes: [Int] = []

// 2
#if os(watchOS)
wantedQrCodes = ids.filter { id in
  let url = QRCode.url(for: id)
  return !FileManager.default.fileExists(atPath: url.path)
}
#endif

// 3
Connectivity.shared.send(
  movieIds: ids,
  delivery: .highPriority,
  wantedQrCodes: wantedQrCodes
)
#if os(watchOS)
func qrCodeImage() -> Image? {
  let path = QRCode.url(for: id).path
  if let image = UIImage(contentsOfFile: path) {
    return Image(uiImage: image)
  } else {
    return Image(systemName: "xmark.circle")
  }
}
#endif
if !ticketOffice.isPurchased(movie) {
  PurchaseTicketView(movie: movie)
} else {
  movie.qrCodeImage()
}

Key Points

  • There are many methods available for sending data. Be sure you choose one based on how quickly you need the data to arrive.
  • If you send an interactive message from your watchOS app, the corresponding iOS app will wake up in the background and become reachable.
  • If you send an interactive message from your iOS app while the watchOS app is not in the foreground, the message will fail.
  • Bundle messages together whenever possible to limit battery consumption.
  • Do not supply a reply handler if you aren’t going to reply.

Where to Go From Here?

In this chapter, you set up the Watch Connectivity framework, learned about the different ways to transfer data between counterpart iOS and watchOS apps, and successfully implemented the application context transfer method.

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 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 Kodeco Personal Plan.

Unlock now