Реалізація 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 не підтримує. Вартість розраховується індивідуально.







