How to Implement Handoff in SwiftUI
Apply .userActivity(_:isActive:_:) to your view to broadcast an NSUserActivity, then catch it on another device with .onContinueUserActivity(_:perform:). Both modifiers live on any SwiftUI View — no AppDelegate wiring needed.
struct NoteView: View {
@State private var text = "Start typing…"
var body: some View {
TextEditor(text: $text)
.userActivity("com.yourapp.editing") { activity in
activity.title = "Editing Note"
activity.userInfo = ["text": text]
activity.isEligibleForHandoff = true
}
.onContinueUserActivity("com.yourapp.editing") { activity in
if let resumed = activity.userInfo?["text"] as? String {
text = resumed
}
}
}
}
Full implementation
The example below models a simple document editor that continuously advertises its state so a nearby signed-in device can offer a Handoff banner in the app switcher. When the user taps the banner, onContinueUserActivity fires and restores the document title and body. Remember to declare your activity type string in Info.plist under the NSUserActivityTypes array key — without that entry the system silently ignores your activity.
import SwiftUI
// MARK: - Shared constants
enum ActivityType {
static let editing = "com.yourapp.editing"
static let browsing = "com.yourapp.browsing"
}
// MARK: - Model
struct NoteDocument {
var id: String = UUID().uuidString
var title: String = "Untitled"
var body: String = ""
// Serialise into NSUserActivity userInfo
var userInfo: [String: String] {
["id": id, "title": title, "body": body]
}
// Restore from userInfo
init(from info: [AnyHashable: Any]) {
id = info["id"] as? String ?? UUID().uuidString
title = info["title"] as? String ?? "Untitled"
body = info["body"] as? String ?? ""
}
init() {}
}
// MARK: - View
struct HandoffDocumentView: View {
@State private var doc = NoteDocument()
@State private var isActive = true // pause broadcast when backgrounded
var body: some View {
NavigationStack {
Form {
Section("Title") {
TextField("Document title", text: $doc.title)
}
Section("Body") {
TextEditor(text: $doc.body)
.frame(minHeight: 200)
}
}
.navigationTitle("Handoff Demo")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Label("Handoff active", systemImage: "arrow.triangle.2.circlepath")
.labelStyle(.iconOnly)
.foregroundStyle(isActive ? .blue : .secondary)
}
}
}
// ── Advertise this activity to nearby devices ──────────────────────
.userActivity(ActivityType.editing, isActive: isActive) { activity in
activity.title = doc.title
activity.userInfo = doc.userInfo
activity.isEligibleForHandoff = true
activity.isEligibleForSearch = true
activity.keywords = [doc.title]
// Required: bundle the activity type in Info.plist too
}
// ── Receive continuation from another device ───────────────────────
.onContinueUserActivity(ActivityType.editing) { activity in
guard let info = activity.userInfo else { return }
doc = NoteDocument(from: info)
}
.onForegroundPhase(isActive: $isActive)
}
}
// MARK: - Convenience: pause broadcast when app goes to background
private extension View {
func onForegroundPhase(isActive: Binding<Bool>) -> some View {
self.onReceive(
NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification)
) { _ in isActive.wrappedValue = false }
.onReceive(
NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)
) { _ in isActive.wrappedValue = true }
}
}
#Preview {
HandoffDocumentView()
}
How it works
-
Info.plist registration. iOS only hands off activity types it recognises. Add
NSUserActivityTypesas an Array to your app target's Info.plist and include each type string (e.g.com.yourapp.editing). Without this, the system discards the activity entirely — the most common source of Handoff "not working." -
.userActivity(_:isActive:_:)modifier. Called whenever the view re-renders, giving you a fresh chance to pack the latest state intoactivity.userInfo. SettingisEligibleForHandoff = trueis mandatory; without it the activity is invisible to other devices even if everything else is correct. -
isActiveflag. TheisActiveparameter lets you pause broadcasting — useful when the user navigates to a different screen or the app backgrounds. The extension wiresUIApplicationlifecycle notifications to flip this flag automatically. -
.onContinueUserActivity(_:perform:). Fires on the receiving device when the user taps the Handoff banner. The closure receives the originalNSUserActivity, so you can readuserInfoand restore state. SwiftUI automatically routes this to whichever view in the hierarchy registered the matching type. -
Same Apple ID + Bluetooth/Wi-Fi. Handoff requires both devices to be signed into the same iCloud account and to have Bluetooth and Wi-Fi enabled. Your code does not need to manage discovery — the OS handles everything below the
NSUserActivitylayer.
Variants
Handing off a URL (web browsing)
If your app has a web-browsable equivalent, set webpageURL instead of (or alongside) userInfo. Safari on the receiving device can then continue the activity even if the app isn't installed.
struct ArticleView: View {
let article: Article // has .url: URL and .title: String
var body: some View {
ScrollView {
ArticleBodyView(article: article)
}
.navigationTitle(article.title)
.userActivity(ActivityType.browsing) { activity in
activity.title = article.title
activity.webpageURL = article.url
activity.isEligibleForHandoff = true
activity.isEligibleForSearch = true
}
.onContinueUserActivity(ActivityType.browsing) { activity in
// deep-link routing would navigate here
print("Continued from:", activity.webpageURL ?? "unknown")
}
}
}
Triggering a navigation jump on continue
If your app uses a NavigationStack with a path, write to the path binding inside onContinueUserActivity to deep-link the user directly to the continued screen. Store an ID in userInfo on the originating device, then on the receiving device fetch the matching model by that ID and push it onto the navigation path. This avoids encoding large data blobs in userInfo (keep it under 1 KB) while still landing the user in exactly the right place.
Common pitfalls
- Missing Info.plist entry. The activity will broadcast but never hand off. Add
NSUserActivityTypesas an Array to Info.plist with your exact type string. This is required on both iOS and macOS targets. - Forgetting
isEligibleForHandoff = true. NewNSUserActivityinstances default this property tofalse. If you omit it, no banner ever appears on the receiving device — and there's no runtime error to tell you why. - Large
userInfopayloads. Apple recommends keepinguserInfounder 1 KB. For rich content (images, full documents), store the data locally and pass only an identifier — then re-fetch on continuation. Oversized payloads cause silent failures on older OS versions. - SwiftUI view lifetime vs. activity lifetime. If your advertising view is pushed deep in a
NavigationStackand popped before the user continues on another device, the activity becomes stale. Invalidate it explicitly withisActive: falsewhen navigating away.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement Handoff in SwiftUI for iOS 17+. Use NSUserActivity with .userActivity(_:isActive:_:) and .onContinueUserActivity(_:perform:). Pause the broadcast when the app backgrounds. Make it accessible (VoiceOver labels on toolbar indicator). Add a #Preview with realistic sample data (a travel note).
Drop this prompt in the Soarias Build phase after your screens are scaffolded — Claude Code will wire up the activity types, Info.plist entry, and navigation restoration in one pass.
Related
FAQ
Does this work on iOS 16?
The .userActivity(_:isActive:_:) and .onContinueUserActivity(_:perform:) SwiftUI modifiers shipped in iOS 14, so the basic Handoff wiring works on iOS 16. However this guide targets iOS 17+ APIs elsewhere (e.g. #Preview, scrollContentBackground), so your full app still requires iOS 17 as the deployment target. The underlying NSUserActivity Handoff protocol has been stable since iOS 8.
How large can the userInfo dictionary be, and what types can it hold?
userInfo is a [AnyHashable: Any] dictionary, but in practice only property-list-compatible types survive the Handoff transport: String, Int, Double, Bool, Data, Date, Array, and Dictionary. Custom objects are silently dropped. Apple's documentation recommends keeping the total serialised size under 1 KB; for larger payloads, store data in iCloud or a shared container and pass only a lookup key via userInfo.
What's the UIKit equivalent?
In UIKit you create and configure an NSUserActivity instance, assign it to UIViewController.userActivity, and call becomeCurrent(). Continuation is handled in UIApplicationDelegate.application(_:continue:restorationHandler:) or UISceneDelegate.scene(_:continue:). The SwiftUI modifiers wrap exactly this pattern, eliminating the delegate boilerplate and automatically scoping the activity to the view's lifetime.
Last reviewed: 2026-05-12 by the Soarias team.