Реализация Handoff между iPhone и iPad
Handoff позволяет пользователю продолжить работу в приложении с одного Apple-устройства на другом. Открыл статью на iPhone — иконка приложения появляется на iPad в Dock, и при нажатии iPad открывает тот же экран в том же месте прокрутки. Реализуется через NSUserActivity и требует правильной настройки на нескольких уровнях.
Предварительные требования
Оба устройства должны быть залогинены под одним Apple ID, Bluetooth и Wi-Fi включены. На уровне проекта — включить Handoff в Capabilities (автоматически добавляет com.apple.developer.associated-domains и нужные entitlements).
В Info.plist указываем NSUserActivityTypes — массив строк-идентификаторов активностей. Соглашение по именованию: com.bundleid.activityname. Активность, не перечисленная в этом массиве, не будет принята системой.
Создание и обновление активности
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 и isEligibleForPrediction — для Spotlight и Siri Suggestions
self.userActivity = activity
activity.becomeCurrent()
}
// Обновляем состояние при прокрутке
func scrollViewDidScroll(_ scrollView: UIScrollView) {
userActivity?.userInfo?["scrollPosition"] = scrollView.contentOffset.y
userActivity?.needsSave = true // триггерит updateUserActivityState перед передачей
}
override func updateUserActivityState(_ activity: NSUserActivity) {
activity.addUserInfoEntries(from: [
"scrollPosition": scrollView.contentOffset.y
])
}
}
needsSave = true — ключевой момент. Система не вызывает updateUserActivityState постоянно — только когда needsSave выставлен. Это значит, что если забыть его выставить при изменении состояния, принимающее устройство получит устаревшие данные.
Обработка на принимающем устройстве
В AppDelegate или 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
// Навигируем к нужному экрану и восстанавливаем позицию
navigator.openArticle(id: articleId, scrollPosition: scrollPosition)
return true
}
Для SwiftUI через .onContinueUserActivity:
WindowGroup {
ContentView()
.onContinueUserActivity("com.myapp.reading-article") { activity in
guard let articleId = activity.userInfo?["articleId"] as? String else { return }
appState.openArticle(id: articleId)
}
}
Типичные ошибки
userInfo в NSUserActivity должен содержать только property list–совместимые типы: String, Int, Double, Bool, Data, Date, Array, Dictionary. Попытка положить туда кастомный объект — silent failure, активность не передаётся без каких-либо ошибок в лог.
Вызов resignCurrent() при уходе с экрана обязателен — иначе старая активность продолжает рекламировать себя на других устройствах, пока не истечёт таймаут системы.
Сроки
3–5 дней с учётом тестирования на двух физических устройствах. Симулятор Handoff не поддерживает. Стоимость рассчитывается индивидуально.







