Implementing Handoff Between iPhone and iPad
Handoff lets user continue work in app from one Apple device to another. Opened article on iPhone — app icon appears on iPad in Dock, tap — iPad opens same screen at same scroll position. Implemented via NSUserActivity and requires proper setup on multiple levels.
Prerequisites
Both devices must be logged in with same Apple ID, Bluetooth and Wi-Fi enabled. At project level — enable Handoff in Capabilities (automatically adds com.apple.developer.associated-domains and needed entitlements).
In Info.plist specify NSUserActivityTypes — array of activity type strings. Naming convention: com.bundleid.activityname. Activity not listed in this array won't be accepted by system.
Creating and Updating Activity
class ArticleViewController: UIViewController {
var article: Article
override func viewDidLoad() {
super.viewDidLoad()
setupUserActivity()
}
private func setupUserActivity() {
let activity = NSUserActivity(activityType: "com.myapp.reading-article")
activity.title = article.title
activity.userInfo = [
"articleId": article.id,
"scrollPosition": 0.0
]
activity.isEligibleForHandoff = true
// isEligibleForSearch and isEligibleForPrediction — for Spotlight and Siri Suggestions
self.userActivity = activity
activity.becomeCurrent()
}
// Update state on scroll
func scrollViewDidScroll(_ scrollView: UIScrollView) {
userActivity?.userInfo?["scrollPosition"] = scrollView.contentOffset.y
userActivity?.needsSave = true // triggers updateUserActivityState before transfer
}
override func updateUserActivityState(_ activity: NSUserActivity) {
activity.addUserInfoEntries(from: [
"scrollPosition": scrollView.contentOffset.y
])
}
}
needsSave = true — key moment. System doesn't call updateUserActivityState constantly — only when needsSave set. Means if forgotten, receiving device gets stale data.
Handling on Receiving Device
In AppDelegate or SceneDelegate:
func application(_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
guard userActivity.activityType == "com.myapp.reading-article",
let articleId = userActivity.userInfo?["articleId"] as? String else {
return false
}
let scrollPosition = userActivity.userInfo?["scrollPosition"] as? CGFloat ?? 0
// Navigate to needed screen and restore position
navigator.openArticle(id: articleId, scrollPosition: scrollPosition)
return true
}
For SwiftUI via .onContinueUserActivity:
WindowGroup {
ContentView()
.onContinueUserActivity("com.myapp.reading-article") { activity in
guard let articleId = activity.userInfo?["articleId"] as? String else { return }
appState.openArticle(id: articleId)
}
}
Typical Mistakes
userInfo in NSUserActivity must contain only property list–compatible types: String, Int, Double, Bool, Data, Date, Array, Dictionary. Trying to put custom object — silent failure, activity doesn't transfer without log errors.
Calling resignCurrent() on screen exit mandatory — otherwise old activity continues advertising itself on other devices until system timeout.
Timeline
3–5 days including testing on two physical devices. Simulator doesn't support Handoff. Cost calculated individually.







