```html SwiftUI: How to Implement Handoff (iOS 17+, 2026)

How to Implement Handoff in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: NSUserActivity Updated: May 12, 2026
TL;DR

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

  1. Info.plist registration. iOS only hands off activity types it recognises. Add NSUserActivityTypes as 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."
  2. .userActivity(_:isActive:_:) modifier. Called whenever the view re-renders, giving you a fresh chance to pack the latest state into activity.userInfo. Setting isEligibleForHandoff = true is mandatory; without it the activity is invisible to other devices even if everything else is correct.
  3. isActive flag. The isActive parameter lets you pause broadcasting — useful when the user navigates to a different screen or the app backgrounds. The extension wires UIApplication lifecycle notifications to flip this flag automatically.
  4. .onContinueUserActivity(_:perform:). Fires on the receiving device when the user taps the Handoff banner. The closure receives the original NSUserActivity, so you can read userInfo and restore state. SwiftUI automatically routes this to whichever view in the hierarchy registered the matching type.
  5. 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 NSUserActivity layer.

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

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.

```