WatchConnectivity: Sharing All Data via User Info
Check out previous blog posts on WatchOS 2 if you haven’t already:
- WatchOS 2: Hello, World
- WatchConnectivity Introduction: Say Goodbye To The Spinner
- WatchConnectivity: Say Hello to WCSession
Background data transfers via User Info should be done when you need to make sure that all your data is transferred (not just the latest like with Application Context). User Info data is queued up for delivery in a FIFO (first-in-first-out) order, so nothing is overwritten.
One examples for when you might want to use this is in a text messaging app – when the last message is just as important as the first to see the full conversation and context. Or if the user updated a few pieces of their profile information and all the changes need to be synced to the Watch profile.
For this tutorial, I’m going to walk through building a food emoji communication app because food and emoji! I LOVE 🍦!
Also, this could potentially be an Apple Watch based grocery list app – you select the food you plan to buy from the list of emojis on the phone, and it goes to the app, which you can then glance at as you shop at the grocery store!
Disclamer
Note that for this app, I’m going to write more of an abstract data updating layer meant for larger applications with multiple places in the UI that need to have the updated data source, so it’ll be over-engineered for the app I’m demoing.
I’m also experimenting with different architectures, especially in Swift, so if you have feedback on how to make the abstract data layer better in Swift, let me know in the comments!
The Setup
For this tutorial, I’m assuming you know how to create a Single View Application in Xcode, and create a simple Table View with a list of Food Emojis. If you’re having problems replicating it, take a look at my FoodSelectionViewController here.
I’m also assuming you know how to create a Watch App and do the basic styling in Interface.storyboard of your Watch Application. If you need help settings this up, make sure to read my WatchOS 2: Hello, World tutorial and the WatchKit: Let’s Create a Table tutorial.
Finally, you should be able to set up the basic singleton to wrap around WCSession and activate it in application:didFinishLaunchingWithOptions in the AppDelegate and applicationDidFinishLaunching in the ExtensionDelegate in your Watch Extension. If not, take a look at my WatchConnectivity: Say Hello to WCSession tutorial.
You should have something that looks like this in your iOS app:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
// in your iOS app import WatchConnectivity class WatchSessionManager: NSObject, WCSessionDelegate { static let sharedManager = WatchSessionManager() private override init() { super.init() } private let session: WCSession? = WCSession.isSupported() ? WCSession.defaultSession() : nil private var validSession: WCSession? { // paired - the user has to have their device paired to the watch // watchAppInstalled - the user must have your watch app installed // Note: if the device is paired, but your watch app is not installed // consider prompting the user to install it for a better experience if let session = session where session.paired && session.watchAppInstalled { return session } return nil } func startSession() { session?.delegate = self session?.activateSession() } } |
And something like this in your Watch App:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
// in your WatchKit Extension import WatchConnectivity class WatchSessionManager: NSObject, WCSessionDelegate { static let sharedManager = WatchSessionManager() private override init() { super.init() } private let session: WCSession = WCSession.defaultSession() func startSession() { session.delegate = self session.activateSession() } } |
And of course, you can always refer to the source code for this tutorial if you ever need extra clarification.
Now, on to the fun stuff 🚀.
Sending Data
In my application, every time the user selects a food item, it needs to be transferred in the background to my Watch App. That means the iOS app is the sender. This is super simple to do.
Just extend the WatchSessionManager singleton in your iOS app to transfer user info:
1 2 3 4 5 6 7 8 9 10 11 12 |
// in your iOS app // MARK: User Info // use when your app needs all the data // FIFO queue extension WatchSessionManager { // Sender func transferUserInfo(userInfo: [String : AnyObject]) -> WCSessionUserInfoTransfer? { return validSession?.transferUserInfo(userInfo) } } |
So now, when the user selects a food item cell, you simply call the above method:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
// FoodSelectionViewController.swift class FoodSelectionViewController: UITableViewController { private let food = ["🍦", "🍮", "🍤","🍉", "🍨", "🍏", "🍌", "🍰", "🍚", "🍓", "🍪", "🍕"] // Table Data Source methods truncated // MARK: Table view delegate override func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) { let foodItem = food[indexPath.row] WatchSessionManager.sharedManager.transferUserInfo(["foodItem" : foodItem]) } } |
That’s it! The selected food item is now in a FIFO queue and will be send over to your Watch App!
Receiving Data
Your Watch App now has to receive the data. This is pretty simple to start with. Just implement the session:didReceiveUserInfo: WCSessionDelegate method.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
// in your WatchKit Extension // MARK: User Info // use when your app needs all the data // FIFO queue extension WatchSessionManager { // Receiver func session(session: WCSession, didReceiveUserInfo userInfo: [String : AnyObject]) { // handle receiving user info // this will be filled in in the Updating Data section below } } |
Updating Data
Now that you received the data, this is the tricky part. Trying to let your Watch Extension’s InterfaceController and other views or data sources that your data has been updated. One way to do this is through NSNotificationCenter, but I’m going to try out a different approach. This is the part that can be done in multiple ways and is a bit over-engineered for this app, so keep this in mind.
Since we’re in Swift, my goal is to change to a value-type model as soon as I can. Unfortunately, as I mentioned in my blog post on WCSession, the WCSessionDelegate can only be implemented on an NSObject. So to mitigate that, I created a DataSource value that can take the userInfo data, and convert it into something immutable and usable by multiple InterfaceControllers. Since user info is received in a FIFO queue order, the DataSource should also keep track of the data in the order received.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
// in your WatchKit Extension struct DataSource { let items: [Item] enum Item { case Food(String) case Unknown } init(items: [Item] = [Item]()) { self.items = items } func insertItemFromData(data: [String : AnyObject]) -> DataSource { let updatedItems: [Item] if let foodItem = data["foodItem"] as? String { updatedItems = [.Food(foodItem)] + items } else { updatedItems = [.Unknown] + items } return DataSource(items: updatedItems) } } |
I can now set up a protocol that will update all parties that need to know about the data change with the most updated data source:
1 2 3 4 5 6 |
// in your WatchKit Extension // WatchSessionManager.swift protocol DataSourceChangedDelegate { func dataSourceDidUpdate(dataSource: DataSource) } |
So now onto the interesting part! Your WatchSessionManager will need to somehow keep track of all the dataSourceChangedDelegates. This could be done through an array, and methods that will add and remove delegates from the array. The WatchSessionManager also needs to keep track of the latest copy of the DataSource, so it can use the data from that DataSource to create a new DataSource with the newest data:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
// in your WatchKit Extension // WatchSessionManager.swift class WatchSessionManager: NSObject, WCSessionDelegate { static let sharedManager = WatchSessionManager() private override init() { super.init() } private let session: WCSession = WCSession.defaultSession() private var dataSource = DataSource() private var dataSourceChangedDelegates = [DataSourceChangedDelegate]() func startSession() { session.delegate = self session.activateSession() } func addDataSourceChangedDelegate(delegate: T) { dataSourceChangedDelegates.append(delegate) } func removeDataSourceChangedDelegate(delegate: T) { for (index, dataSourceDelegate) in dataSourceChangedDelegates.enumerate() { if let dataSourceDelegate = dataSourceDelegate as? T where dataSourceDelegate == delegate { dataSourceChangedDelegates.removeAtIndex(index) break } } } } |
We can now add the implementation for when the user info is received:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
// in your WatchKit Extension // WatchSessionManager.swift // MARK: User Info // use when your app needs all the data // FIFO queue extension WatchSessionManager { // Receiver func session(session: WCSession, didReceiveUserInfo userInfo: [String : AnyObject]) { // handle receiving user info dispatch_async(dispatch_get_main_queue()) { [weak self] in if let dataSource = self?.dataSource.insertItemFromData(userInfo) { self?.dataSource = dataSource self?.dataSourceChangedDelegates.forEach { $0.dataSourceDidUpdate(dataSource) } } } } } |
Now, we just need to make sure our InterfaceController is DataSourceChangedDelegate, and is kept track of by the WatchSessionManager.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
// in your WatchKit Extension // InterfaceController.swift class InterfaceController: WKInterfaceController, DataSourceChangedDelegate { @IBOutlet var foodTable: WKInterfaceTable! override func awakeWithContext(context: AnyObject?) { super.awakeWithContext(context) WatchSessionManager.sharedManager.addDataSourceChangedDelegate(self) loadTableData(DataSource()) } override func didDeactivate() { // remove InterfaceController as a dataSourceChangedDelegate // to prevent memory leaks WatchSessionManager.sharedManager.removeDataSourceChangedDelegate(self) super.didDeactivate() } // MARK: DataSourceUpdatedDelegate // update the table once the data is changed! func dataSourceDidUpdate(dataSource: DataSource) { loadTableData(dataSource) } } private extension InterfaceController { private func loadTableData(dataSource: DataSource) { foodTable.setNumberOfRows(dataSource.items.count, withRowType: "FoodTableRowController") for (index, item) in dataSource.items.enumerate() { if let row = foodTable.rowControllerAtIndex(index) as? FoodTableRowController { switch item { case .Food(let foodItem): row.foodLabel.setText(foodItem) case .Unknown: row.foodLabel.setText("¯\\_(ツ)_/¯") } } } } } |
And that’s it!
You can view the full source code on Github here.