Implementing Handoff In iOS and macOS

by
Tags: , , , , ,
Category:

Handoff is a neat feature that was introduced in iOS 8 and macOS (then OS X) v10.10. This capability allows an app to pass data across macOS and iOS devices so that a task started on one device can be completed on another device. The difference between this and continuing an activity by saving a file to a place like iCloud and resuming the activity on another device is context. Meaning that when you use handoff, unlike other methods, the user of your app will be in the exact same location, when the app is opened on a second device, as they were on the first device.
An example of this behavior is a user typing in your app on a screen that takes a few steps to get to, continuing an app on the second device will automatically take that user to the same screen where they will see the same text and even have the keyboard visible and ready for them to continue typing. The alternative is for the user to wait for the data to sync between all devices then navigating to the same place in the app and tapping on the text box to make the keyboard show. This may sound trivial but the user’s experience is much better when the app knows what behavior is desired and automatically does as much of the work as possible.

Introduction

In this tutorial, we will walk through setting up handoff on both iOS and macOS. We are going to use a simple app that stores time stamps. On iOS there is a button to allow the user to create time stamps while the macOS app will generate a new time stamp every time the app is launched. Both apps use a master-detail controller to show the time stamp on the details screen when one is selected on the master side. When we are finished, any time stamp that is being viewed on the details side of the screen will be available for another app to open and view, even if that time stamp does not exist on the other device. The starter project can be found on GitHub.

We will implement Handoff on both platforms simultaneously and will re-use as much of the code as possible so that the two implementations are nearly identical. Please note that for handoff to work between a macOS app and an iOS app, the developer’s Team ID must be the same. Also, the mac app must be signed. In the project settings under the macOS app target on the “General” tab, look for the “Signing” section as seen here.

How to enable development signing

If your app is a document-based app, Handoff is automatically supported. Because of this, we will not look at document-based apps. Rather, we will implement the manual way of supporting Handoff. To do this, we will handle call backs in the AppDelegate and we will manually create and update NSUserActivity objects.

Getting Started With Handoff

The first step is to declare the activities that can be continued by an app. This lets the OS know that if another device is broadcasting an NSUserActivity of the same type, it can be continued using this app. So, in the Info.plist file, make a new entry where the key is NSUserTypes and the value is an array of strings. Each string represents one activity and should be in reverse DNS style formatting. For example, our activity will be “com.chariotsolutions.ListHandoff.viewTimestamp”, as seen in the following image.

Adding an activity type supported by Handoff

Working with NSUserActivity

The next step is to create the User Activity (NSUserActivity). The User Activity is used to describe what actions can be passed to another device. We are only going to pass the viewing of an individual timestamp. However, we could create a separate NSUserActivity for viewing a list or for editing a timestamp if we wanted to support those activities. Since all of our user interaction is filtered through ListTableDataSource, and this code is shared by both iOS and macOS, this is the perfect place to manage the NSUserActivity objects. Therefore, in ListTableDataSource we are going to add a property to hold the NSUserActivity:

var currentActivity: NSUserActivity?

The currentActivity will then be managed as the user selects Events in the table view. Continuing, add a method to handle user selection before the event is passed back to the delegate:

func userDidSelect(event: Event?, notifyDelegate notify: Bool) {
    if notify {
        delegate?.didSelect(event: event)
    }
}

Next, we will update the mac app to call this by opening ListTableDataSource+macOS.swift, which has an extension of ListTableDataSource that is available in the macOS app only, and replace line 80 with:

userDidSelect(event: event, notifyDelegate: true)

In addition, the iOS app needs to be updated. Currently, selecting a UITableViewCell is automatically transitioning the app to the next screen so the user interaction is not being handled at all. However, we will fix this by implementing the following method in ListTableDataSource+iOS.swift, which is an extension of ListTableDataSource that is available in the iOS app only:

func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    userDidSelect(event: event(atIndexPath: indexPath), notifyDelegate: false)
}

Now, any time the user selects a timestamp from the list, we will know about it. Although, we still need to update the app on the iPhone so that when the user presses the back button to return to the list, the user activity gets deselected. To do so, open the iOS app’s MasterViewController class, locate viewWillAppear(_:) and add the following just before the call to super.

if clearsSelectionOnViewWillAppear {
    dataSource.userDidSelect(event: nil, notifyDelegate: false)
}

Continuing on, we can update userDidSelect(event:notifyDelegate:) to create, update or destroy the user activity.

func userDidSelect(event: Event?, notifyDelegate notify: Bool) {
    if notify {
        delegate?.didSelect(event: event)
    }
    if let event = event {
        // The user selected an event so we need to create a new activity
        let userInfo = ["timestamp": event.timestamp ?? NSDate()]
        if let existingActivity = currentActivity {
            // An activity already exists so we will just update it
            existingActivity.userInfo = userInfo
            existingActivity.needsSave = true
        } else {
            // we need to create a new activity
            currentActivity = NSUserActivity(activityType: "com.chariotsolutions.ListHandoff.viewTimestamp")
            currentActivity?.userInfo = userInfo
            currentActivity?.title = "View Timestamp"
        }
        currentActivity?.becomeCurrent()
    } else {
        // The user de-selected an event so we need to destroy the previous activity
        currentActivity?.invalidate()
        currentActivity = nil
    }
}

In the previous code, if the event is not nil, we check to see if a user activity already exists. If it does, we update the user data so that it is current. But, if an activity does not exist, then we create the new user activity. If, on the other hand, the event is nil, then we invalidate the activity and nil it out to free up the memory. We must include currentActivity?.becomeCurrent() in order for the activity to be the one published by the OS as a handoff activity.
Until now, the iOS app’s MasterViewController has had no need to know about event selections because the navigations happen automatically. However, during handoff, there is no user interaction so we must provide a way for the MasterViewController to navigate properly when handoff occurs. Consequently, we must make two changes. First, in viewDidLoad() set the delegate on the data source.

dataSource.delegate = self

Second, implement the protocol such that when an event is selected, the index path for that event is determined, that index path is selected on the table view and, finally, the segue to show the details view is programmatically initiated. To do this, create an extension to the iOS app’s MasterViewController that implements ListTableDataSourceDelegate.

extension MasterViewController: ListTableDataSourceDelegate {
    func didSelect(event: Event?) {
        if let event = event {
            let indexPath = dataSource.fetchedResultsController.indexPath(forObject: event)
            tableView.selectRow(at: indexPath, animated: true, scrollPosition: .middle)
            performSegue(withIdentifier: "showDetail", sender: event)
        }
    }
}

Note that this method is already implemented in the macOS app because that app does not automatically transition to the details view on user interaction. Therefore, this behavior was already set up so that we could handle user interaction appropriately.

Continuing An Activity With Handoff

At this point, the part of the app that publishes an activity is complete and if user activities are continued, the UI will transition to the details view. The user activities that are ready for Handoff will be automatically advertised to near-by devices. Next, we will set up the apps to continue an activity by handling events that are passed to the delegate.

In the iOS app’s AppDelegate we need to add two methods. The first tells iOS that it, not the app, should handle notifying the user about an activity, if necessary. For example, if the data is taking too long to transfer from one device to the next. The second handles the user activity once the data has been received.

func application(_ application: UIApplication, willContinueUserActivityWithType userActivityType: String) -> Bool {
    return false
}
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([Any]?) -> Void) -> Bool {
    var handled = false
    if let date = userActivity.userInfo?["timestamp"] as? NSDate {
        let splitViewController = self.window?.rootViewController as? UISplitViewController
        let masterNavigationController = splitViewController?.viewControllers[0] as? UINavigationController
        if let controller = masterNavigationController?.topViewController as? MasterViewController {
            let event = CoreDataHelper.shared.insertNewObject()
            event.timestamp = date
            controller.dataSource.userDidSelect(event: event, notifyDelegate: true)
            handled = true
        }
    }
    return handled
}

The final part of the iOS app is to handle failures. Naturally, we want to gracefully notify the user that the activity is not able to be continued so that they are not waiting unnecessarily. But, if we do not implement this method, iOS will notify the user for us. Add the following to AppDelegate.

func application(_ application: UIApplication, didFailToContinueUserActivityWithType userActivityType: String, error: Error) {
    let alert = UIAlertController(title: "Unable to continue activity", message: error.localizedDescription, preferredStyle: .alert)
    let ok = UIAlertAction(title: "OK", style: .default) { (action) in
        alert.dismiss(animated: true, completion: nil)
    }
    alert.addAction(ok)
    window?.rootViewController?.present(alert, animated: true, completion: nil)
}

Switching back to the macOS app, we need to implement the AppDelegate methods. Again, there are three methods to be implemented. Similar to the iOS app, the first method will tell the OS to handle notifying the user of activity that should be reported. The second method handles continuing an activity. Finally, the third method handles gracefully notifying the user when something goes wrong.

func application(_ application: NSApplication, willContinueUserActivityWithType userActivityType: String) -> Bool {
    // let iOS notify the user of any activity that is happening
    return false
}
func application(_ application: NSApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([Any]) -> Void) -> Bool {
    var handled = false
    if let date = userActivity.userInfo?["timestamp"] as? NSDate {
        let splitViewController = application.windows[0].contentViewController as? NSSplitViewController
        let master = splitViewController?.splitViewItems[0].viewController as? MasterViewController
        if let controller = master {
            let event = CoreDataHelper.shared.insertNewObject()
            event.timestamp = date
            controller.dataSource.userDidSelect(event: event, notifyDelegate: true)
            handled = true
        }
    }
    return handled
}
func application(_ application: NSApplication, didFailToContinueUserActivityWithType userActivityType: String, error: Error) {
    let alert = NSAlert()
    alert.messageText = "Unable to continue activity"
    alert.informativeText = error.localizedDescription
    alert.addButton(withTitle: "OK")
    _ = alert.runModal()
}

That is it! runt he app on your iPhone, iPad and/or Mac and enjoy Handoff! The completed project can be found on GitHub.

Debugging Handoff

There are a couple things that might be helpful to know if this doesn’t work for you. First, as mentioned before, the macOS app must be signed. Second, Handoff makes sure the devices are close by each other so keep the devices close. There are also a few requirements for your device’s set up. Both Bluetooth and Wi-Fi must be turned on. If the devices are connected to the same Wi-Fi network that is even better, although it is not technically required. Each device also has to have Handoff turned on. On both iOS and macOS devices, this setting is in Settings under “General”.