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.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.didActivate()
. This is analogous to viewDidAppear(_:)
on iOS.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.
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
Two new groups now appear in the project navigator:
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:
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.
UINavigationController
.UIPageViewController
.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.