Getting started with WatchKit and WatchConnectivity iOS 08.07.2020

Introducing WatchKit

A couple of months after announcing the Apple Watch, Apple provided eager developers the tools to start building Watch apps. Apple bundled the primary framework, called WatchKit, with Xcode 6.2.

WatchKit is nothing more than a group of classes and Interface Builder additions that you can wire together to get an Apple Watch app working. Some of the important classes are:

  • WKInterfaceController. This is the WatchKit version of UIViewController. WKInterfaceController acts as the controller in the familiar model-view-controller pattern, and instances of WKInterfaceObject are used to update the UI.
  • WKInterfaceObject. watchOS delivers 19 interface controls with WatchKit, all of which inherit from WKInterfaceObject, which itself inherits from NSObject. Many of the interface controls have UIKit counterparts, but some are unique to WatchKit.
  • WKInterfaceDevice. This class provides all of the information about the Watch, like screen size and locale.

WKInterfaceController has a lifecycle, just like UIViewController. It’s much simpler, though — there are four main methods you need to know:

  • awake(withContext:) is called on WKInterfaceController immediately after the controller is loaded from a storyboard. This method has a parameter for an optional context object that can be whatever you want it to be, like a model object, an ID or a string. Also, when this method is called, WatchKit has already connected any IBOutlets you might have set up.
  • When willActivate() is called, WatchKit is letting you know the controller is about to be displayed onscreen. Just as with viewWillAppear(_:) on iOS, you only need to use this method to run any last-minute tasks, or anything that needs to run each time you display the controller. This method can be called repeatedly while a user is interacting with your Watch app.
  • If there is anything you need to do once the system has finished initializing and displaying the controller, you can override the method didActivate(). This is analogous to viewDidAppear(_:) on iOS.
  • Finally, there’s didDeactivate(), which is called when the controller’s interface goes offscreen, such as when the user navigates away from the interface controller or when the Watch terminates the app. This is where you should perform any cleanup or save any state.

WatchKit provides several amazingly convenient new methods you can override to pass context data in between controllers.

  • contextForSegue(withIdentifier:) returns a context object of type Any. Use this to pass custom objects or values between controllers.
  • awake(withContext:) is called when your WKInterfaceController’s UI is set up. Controllers can receive context objects from methods like contextForSegue(withIdentifier:).

Here’s an example of using a segue to pass a context object to the next controller:

override func contextForSegue(withIdentifier segueIdentifier: String) -> Any? {
    if segueIdentifier == "MovieDetails" {
        return movies[0]
    }
    return nil
}

You’ll build watch apps for watchOS as extensions, just as you might build a Today extension. These are dependent apps — the Apple Watch can’t install them without a paired iPhone.

The Watch display

Right out of the gate, you’ll need to support multiple screen sizes for the Apple Watch.

  • The 38 mm Watch screen is 272 pixels wide by 340 pixels tall.
  • The 42 mm Watch screen is 312 pixels wide by 390 pixels tall.

Luckily for developers, both Watches share an aspect ratio of 4:5, so, at least for the time being, you won’t have to do a ton of extra work to support both screen sizes.

Hello world

Create a new project

  1. Select File\New\Target... from the Xcode menu.
  2. Select watchOS\Application\iOS App with WatchKit App in the target template window.
  3. Click Next. In the target options window, type HelloAppleWatch WatchKit App in the Product Name field uncheck the Include Notification Scene option and click Finish.
  4. Click the Activate button in the pop-up window that appears. This creates an option on the Scheme menu to run the Watch app. To try it out, select HelloAppleWatch WatchKit App\iPhone 7 Plus + Apple Watch Series 2 - 42mm from the Scheme menu.

Two new groups now appear in the project navigator:

  • HelloAppleWatch WatchKit App contains Interface.storyboard, which you’ll use to lay out your app.
  • HelloAppleWatch WatchKit App Extension contains InterfaceController.swift, which is similar to UIViewController on iOS.

Let's add some UI elements. Open Interface.storyboard. Then show the Utilities and the Object Library. Enter label in the search field, and then drag a label from the Object Library onto the single interface controller. Show the Attributes Inspector to see options for customizing the label.

Make the following changes in the Attributes Inspector to adjust its appearance:

  • Set Text to Hello, World!.
  • Set Alignment\Horizontal to Center.
  • Set Alignment\Vertical to Center.

Notice that Size\Width and Size\Height are both Size to Fit Content, so the label resizes itself to fit its text.

Build and run.

Congratulations on creating your first Watch app!

Layout. Watch app layout is completely different from iOS layout. The first thing you’ll notice: you can’t move or resize UI objects by dragging them around in the interface controller. When you drag an object onto the controller, it slots in under the previous objects, and the screen fills up pretty fast. To organize objects side-by-side, you use groups, which are a lot like stack views in iOS

In order for us to put it side-by-side we need to use Groups. Groups are very similar to StackViews in iOS we can set the layout as Horizontal, Vertical or even as Overlap, we can set the space between objects and much more.

Next you need to create an outlet for the label in InterfaceController.swift. Open the assistant editor and check that it’s set to Automatic so that it displays InterfaceController.swift.

Select the label and, holding down the Control key, drag from the label to the space just below the class title in InterfaceController.swift . Xcode will prompt you to Insert Outlet. Release your mouse button and a pop-up window will appear. Check that Type is set to WKInterfaceLabel, then set Name to label and click Connect.

Your code now has a reference to the label in your Watch app interface, so you can use this to set its text.

Add the following line to willActivate(), below super.willActivate():

label.setText("Hello, Apple Watch!")

Navigation

In WatchKit, unlike in UIKit, you are strictly limited to following navigation methods.

  • Hierarchical. Similar to UINavigationController.
  • Page-based. Similar to UIPageViewController.
  • Modal. Any type of presentation or dismissal transition.
  • Menus. A system modal menu with option buttons.

You can simply Control-drag from a button to a controller, or simply call pushController(withName:context:) in your code.

You can display a WKInterfaceController modally by either wiring it up in Interface Builder or calling one of the following methods in code:

  • presentController(withName:context:). This method displays a single WKInterfaceController modally. Note that you can pass a context object just as you can in hierarchical navigation.
  • presentController(withNamesAndContexts:). This method lets you display several instances of WKInterfaceController modally using page-based navigation, as previously discussed.
  • dismissController(). Use this method to dismiss the modal interface controller.

Table view

WKInterfaceTable is similar to UITableView in that it manages the display of a collection of data, but the similarities pretty much end there.

For starters, WKInterfaceTable can only display a single dimension of data — no sections. This forces you to give your interfaces simple data structures.

WKInterfaceTable works perfectly with storyboards. Once you’ve connected a table from your storyboard to an IBOutlet, you simply set the number of rows to display and the row type, like this:

table.setNumberOfRows(10, withRowType: "MovieRow")

This single line sets up a table with 10 rows. You don’t need to implement any data source or delegate protocols or override any methods.

The row type in the code above is an identifier that behaves just like a UITableViewCell reuse identifier.

First you need to display a list of all the items you have available. From the Object Library, drag a table into the controller.

Drag two labels into the placeholder table row group. Select the group of the table row and change the Layout to Vertical. Select the group of the table row and set the Size Height to Size To Fit Content. This will expand your table row to fit both labels.

Just as each cell in a UITableView is powered by a UITableViewCell subclass, WKInterfaceTable requires you to create a row controller to represent each row. Create a new class that inherits from NSObject and name the file MovieRowController.

Open Movies WatchKit App\Interface.storyboard in the main editor and select the Table Row Controller from the document outline.

In the Identity Inspector, change the Class of the row to MovieRowController. Then in the Attributes Inspector, change the Identifier to MovieRowType.

Open Movies WatchKit Extension\MovieRowController.swift in the assistant editor. Right-click and drag from the top label in the row to MovieRowController and create a new outlet named titleLabel. Right-click and drag from the bottom label in the row to MovieRowController and create a new outlet named yearLabel.

import WatchKit

class MovieRowController: NSObject {
    @IBOutlet var titleLabel: WKInterfaceLabel!
    @IBOutlet var yearLabel: WKInterfaceLabel!
}

While you have Interface.storyboard open in the main editor, open InterfaceController.swift in the assistant editor. Right-click and drag from the table in the storyboard to InterfaceController to create a new outlet named table.

Unlike UITableView, WKInterfaceTable has no delegate or data source protocols that you have to implement. Instead, there are only two main functions you need to use to add and display data:

  • setNumberOfRows(_:withRowType:) specifies the number of rows in the table as well as each row controller’s identifier, which in this case would be the string MovieRowType that you added to the row controller in your storyboard. Use this method if all the rows in the table have the same identifier.
  • rowController(at:) returns a row controller at a given index. You must call this after adding rows to a table with either setNumberOfRows(_:withRowType:) or insertRows(at:withRowType:).

Open Movies WatchKit Extension\InterfaceController.swift and implement awake(withContext:) for InterfaceController:

import WatchKit

struct Movie {
    let title: String
    let year: Int
}

class InterfaceController: WKInterfaceController {
    @IBOutlet weak var table: WKInterfaceTable!
    var items: [Movie] = []

    override func awake(withContext context: Any?) {
        super.awake(withContext: context)

        items.append(contentsOf: (1990...2000).map { index in Movie(title: "Movie \(index)", year: index) })

        table.setNumberOfRows(items.count, withRowType: "MovieRowType")
        for (index, movie) in items.enumerated() {
            let controller = table.rowController(at: index) as! MovieRowController
            controller.titleLabel.setText(movie.title)
            controller.yearLabel.setText(String(movie.year))
        }
    }
}

To include the directions, you need another controller to display movie details. Open Movies WatchKit App\Interface.storyboard and drag a new interface controller into the scene.

From the document outline, select the MovieRowType, right-click and drag to your new controller. When you let go, a modal will appear. Select the Push option to create a segue between the two controllers.

Open InterfaceController.swift and add the following method:

override func contextForSegue(withIdentifier segueIdentifier: String, in table: WKInterfaceTable, rowIndex: Int) -> Any? {
    return items[rowIndex]
}

Also, you can define what happens when the user selects a row.

override func table(_ table: WKInterfaceTable, didSelectRowAt rowIndex: Int) {
      let movie = items[rowIndex]
      pushController(withName: "SomeController", context: movie)
 }

Open MovieDetailController.swift and replace awake(withContext:) with the following code:

import WatchKit

class MovieDetailController: WKInterfaceController {
    override func awake(withContext context: Any?) {
        super.awake(withContext: context)

        if let movie = context as? Movie {
            print(movie)
        }
    }
}

WatchConnectivity

Watch Connectivity lets an iPhone app and its counterpart Watch app transfer data and files back and forth. If both apps are active, communication happens in real-time; otherwise, communication happens in the background so data can be available as soon as the receiving app launches.

In order to communicate between our Watch App and iPhone App, we need to implement WatchConnectivity framework. Implementing means setting both apps up to receive incoming WatchConnectivity content, in an early stage of the apps’ lifecycle. So we need to make sure to set a code path that can be executed even in background launch.

To set the apps up we need to verify that WCSession is supported (only Watch and iPhone support this), then we need to create an instance of WCSession, set its delegate, then activate it and implement WCSessionDelegate methods.

For our test app, you’ll add the setup and activation code to the iPhone’s app delegate and the Watch’s extension delegate in application(_:didFinishLaunchingWithOptions:) and applicationDidFinishLaunching(), respectively. These methods are always executed — even when the app launches in the background.

You’ll use the WCSession class and WCSessionDelegate protocol to configure and activate connectivity sessions in both apps.

iPhone connectivity setup. Open AppDelegate.swift and update the import section to include the Watch Connectivity framework:

import WatchConnectivity

The app delegate needs to conform to the WCSessionDelegate protocol to receive communication from the Watch. Update the class extension at the bottom of the file to include the WCSessionDelegate protocol:

import UIKit
import WatchConnectivity

let NotificationSetScoresOnPhone = "SetScoresOnPhone"

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    lazy var notificationCenter: NotificationCenter = {
        return NotificationCenter.default
    }()

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        setupWatchConnectivity()
        setupNotificationCenter()

        return true
    }

    func setupNotificationCenter() {
        notificationCenter.addObserver(forName: NSNotification.Name(rawValue: NotificationSetScoresOnPhone), object: nil, queue: nil) { (notification:Notification) -> Void in
            self.sendScoresToWatch(notification)
        }
    }

    func sendScoresToWatch(_ notification: Notification) {
        if WCSession.isSupported() {
            if let data = notification.userInfo as? [String: Int] {
                let session = WCSession.default
                if session.isWatchAppInstalled {
                    do {
                        let dictionary = ["data": data]
                        try session.updateApplicationContext(dictionary)
                    } catch {
                        print("Error sendScoresToWatch: \(error)")
                    }
                }

            }
        }
    }
}

extension AppDelegate: WCSessionDelegate {
    func sessionDidBecomeInactive(_ session: WCSession) {
        print("WC Session did become inactive")
    }

    func sessionDidDeactivate(_ session: WCSession) {
        print("WC Session did deactivate")
        WCSession.default.activate()
    }

    func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
        if let error = error {
            print("WC Session activation failed with error: \(error.localizedDescription)")
            return
        }
        print("WC Session activated with state: \(activationState.rawValue)")
    }

    func setupWatchConnectivity() {
        if WCSession.isSupported() {
            let session = WCSession.default
            session.delegate = self
            session.activate()
        }
    }
}    

Watch connectivity setup. Open ExtensionDelegate.swift and add the following import to include the Watch Connectivity framework:

import WatchConnectivity

Update the class extension at the bottom of the file to include the WCSessionDelegate protocol:

import WatchKit
import WatchConnectivity

class ExtensionDelegate: NSObject, WKExtensionDelegate {
    func applicationDidFinishLaunching() {
        setupWatchConnectivity()
    }
}

extension ExtensionDelegate : WCSessionDelegate {
    func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
        if let error = error {
            print("WC Session activation failed with error: " + "\(error.localizedDescription)")
            return
        }
        print("WC Session activated with state: \(activationState.rawValue)")
    }

    func setupWatchConnectivity() {
        if WCSession.isSupported() {
            let session = WCSession.default
            session.delegate = self
            session.activate()
        }
    }

    func session(_ session: WCSession, didReceiveApplicationContext applicationContext:[String:Any]) {
        if let data = applicationContext["data"] as? [String] {
            print(data)
            DispatchQueue.main.async {
                WKInterfaceController.reloadRootPageControllers( withNames: ["SomeController"], contexts: nil,
                                                                 orientation: WKPageOrientation.vertical, pageIndex: 0)
            }
        }
    }
}

Build and run the your Watch scheme; this will launch the Watch app in the Watch simulator. Next, build and run the iPhone scheme to launch the iPhone app.

Device-to-device communication

There are two types of device-to-device communication in Watch Connectivity: interactive messaging and background transfers.

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

When both apps are active, establishing a session allows immediate communication via interactive messaging. Before you implement interactive messaging in your app, consider how likely it is for your iPhone app and Watch app to be active at the same time.

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

Background transfers let iOS and watchOS choose a good time to transfer data between apps, based on such things as the battery use and how much other data is still waiting to be transferred. This has the benefit of reducing battery usage while still guaranteeing the data transfers in a timely manner.

There are three types of background transfers: user info, file, and application context.

The application context transfer is the most appropriate transfer method for the our apps. These are like user info transfers, in that both let you transfer a dictionary containing data from one app to another. What makes them different is that only the most recent dictionary of data, called a context, transfers over. That means, if one app starts multiple transfers, the framework discards everything but the most recent one, and the counterpart app will only receive the last dictionary sent.

The WCSession method updateApplicationContext(_:) sends the context, and the WCSessionDelegate method session(_:didReceiveApplicationContext:) receives it in the counterpart app.

iPhone-to-Watch communication. Find sendScoresToWatch(_:) in AppDelegate.swift. This function is called when the user click in a ViewController in the iPhone app, which fires off the NotificationSetScoresOnPhone notification.

func sendScoresToWatch(_ notification: Notification) {
    if WCSession.isSupported() {
        if let data = notification.userInfo as? [String: Int] {
            let session = WCSession.default
            if session.isWatchAppInstalled {
                do {
                    let dictionary = ["data": data]
                    try session.updateApplicationContext(dictionary)
                } catch {
                    print("Error sendScoresToWatch: \(error)")
                }
            }

        }
    }
}

First you check if the current device supports Watch Connectivity. You set the constant session to the default connectivity session, and verify installation of the counterpart Watch app. If the user hasn’t installed the Watch app, there’s no point in trying to communicate with it. Finally, you call updateApplicationContext(_:) on the active session to transfer to the Watch a dictionary with the data key set.

Now that the iPhone app is transferring purchased movie tickets, you’ll set up the Watch app to receive them. Open ExtensionDelegate.swift and add the following to the end of the class:

func session(_ session: WCSession, didReceiveApplicationContext applicationContext:[String:Any]) {
    if let data = applicationContext["data"] as? [String] {
        print(data)
        DispatchQueue.main.async {
            WKInterfaceController.reloadRootPageControllers( withNames: ["SomeController"], contexts: nil,
                                                             orientation: WKPageOrientation.vertical, pageIndex: 0)
        }
    }
}

Your code implements the optional WCSessionDelegate protocol method session(_:didReceiveApplicationContext:). The active connectivity session calls this method when it receives context data from the counterpart iPhone app.

Finally, you reload the root interface controller on the main queue to display the result. The delegate callback takes place on a background queue, so the reload must happen on the main queue to trigger UI updates.

import UIKit

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
    }

    @IBAction func onButtonTapped(_ sender: Any) {
        let scores = ["Bob": Int.random(in: 0 ..< 10), "Alice": Int.random(in: 0 ..< 10), "Arthur": 42]
        NotificationCenter.default.post(name: Notification.Name(rawValue: NotificationSetScoresOnPhone), object: nil, userInfo: scores)
    }

}

Watch-to-iPhone communication. You need to perform a similar transfer between the Watch and the counterpart iPhone app. Open ExtensionDelegate.swift and add sendDataToPhone(_:). When the user click on the Watch, the NotificationPurchasedMovieOnWatch notification fires and the extension delegate calls sendDataToPhone(_:).

func sendDataToPhone(_ notification:Notification) { 
    if WCSession.isSupported() {
        if let data = ... { 
            do {
                let dictionary = ["data": data] 
                try WCSession.default.updateApplicationContext(dictionary) 
            } catch {
                print("Error: \(error)")
            }
        } 
    }
}

You call updateApplicationContext(_:) on the active session to transfer a dictionary to the iPhone with the data key.

Open AppDelegate.swift and add the following code to the AppDelegate extension:

func session(_ session: WCSession, didReceiveApplicationContext applicationContext:[String:Any]) {
    if let data = applicationContext["data"] as? [String] {
        DispatchQueue.main.async {
            let notificationCenter = NotificationCenter.default 
            notificationCenter.post(name: NSNotification.Name(rawValue: NotificationDataFromWatch), object: nil)
        }
    } 
}

The app delegate implements the optional WCSessionDelegate protocol method session(_:didReceiveApplicationContext:). The active connectivity session uses this method to receive context data from the counterpart Watch app.

Finally, you post the notification NotificationDataFromWatch on the main queue signifying there are new data. The view controllers listening for this notification now know to update their views to show the newly purchased movie tickets. You use the main queue to post the notification, because the delegate callback happens on a background queue, and all UI updates need to happen on the main queue.

TextInputController

@IBAction func addNewItem() {
    let suggestions = ["Hello", "Ciao"]

    presentTextInputController(withSuggestions: suggestions, allowedInputMode: WKTextInputMode.allowEmoji, completion: { (result) -> Void in

        guard let choice = result else { return }

        let newItem = choice[0] as! String
        print(newItem)
    })
}

Reaction to Digital crown scroll

Let’s see how you can update the count displayed when the user rotates the digital crown.

Go to InterfaceController.swift. Add a new private property here, called crownDelta. Add another property called count, which represents the count displayed on the watch app. Next, extent the InterfaceController.swift to conform to the WKCrownDelegate protocol, as shown in the code below:

class InterfaceController: WKInterfaceController {
    private var crownDelta = 0.0

    private var count: Int = 0 {
        didSet {
            setCount(count: String(count))
        }
    }

    override func awake(withContext context: Any?) {
        super.awake(withContext: context)
        crownSequencer.delegate = self
    }

    override func willActivate() {
        super.willActivate()
        crownSequencer.focus()
    }
}

extension InterfaceController: WKCrownDelegate {
    func crownDidRotate(_ crownSequencer: WKCrownSequencer?, rotationalDelta: Double) {
        crownDelta += rotationalDelta
        if crownDelta > 0.1 {
            count += 1
            crownDelta = 0.0
        } else if crownDelta < -0.1 {
            count -= 1
            crownDelta = 0.0
        }
    }
}

Note that the WKInterfaceController has property crownSequencer, which represents the digital crown. We set its delegate to self, and call the focus() function to begin delivery of its events.