Part 1 argued that Continuity is three different contracts wearing one brand name. Handoff is the one with an actual API, and this part is that API at its smallest: an activity object on the sending side, one Info.plist key on the receiving side, and a closure that puts the reader back where they were. Everything below is runnable.
What it is not is a settled answer to where the activity arrives. That question has a real defect underneath it on iOS, and it is part 3's whole subject. This part gets you a working Handoff and then says plainly which parts of it you should not yet trust.
The sending side is one object
An NSUserActivity is a type string, a title, a dictionary and a couple of flags. The class has been available since iOS 8.0 and macOS 10.10. Availability rows in this series are read from the machine-readable headers of Apple's markdown doc variants, fetched on 2026-09-23, never from prose.
import Foundation
enum ActivityType {
// Reverse-DNS, and the exact string every
// receiving target declares in Info.plist.
static let viewItem = "com.example.app.view-item"
}
func viewItemActivity(
id: String,
name: String
) -> NSUserActivity {
let activity = NSUserActivity(
activityType: ActivityType.viewItem
)
activity.title = name
activity.userInfo = ["id": id]
activity.isEligibleForHandoff = true
activity.becomeCurrent()
return activity
}Four decisions are packed into that. The activity type is reverse-DNS by convention and is the join between the two apps: it has to be byte-identical on both sides, which is why it lives in one constant rather than being typed twice. The title is what the user sees on the receiving device's Handoff affordance, so it is user-facing text and should be localised. The userInfo is the payload, and it is small on purpose: part 4 covers why, and what Apple actually published about its size. And isEligibleForHandoff is the flag that says this activity is for Handoff specifically, rather than for search or prediction.
becomeCurrent() is the moment the activity starts being advertised. Call it when the user arrives at the state you want to hand off, not when the app launches.
The function returns the activity rather than discarding it, because an activity nobody holds can be deallocated. Apple states the retention requirement explicitly only for activities made eligible for search: "Your app must maintain a strong reference to any activity objects you make eligible for search". For a pure Handoff activity, treat it as ordinary object-lifetime care. Hold it for as long as the state it describes is the current one.
Every receiving target declares the type
The sending half above will happily run in an app whose counterpart can never accept it. The receiving half is not code at all. It is one key in the Info.plist of every target that must receive, which on a typical Apple-platform product means the iPhone app, the iPad app and the Mac app separately.
To specify the activity types you support, add the NSUserActivityTypes key to your app's Info.plist file… set its value to an array of strings. For each string, specify one of the activity types you use to create your NSUserActivity objects.
Apple, NSUserActivity overview
<key>NSUserActivityTypes</key>
<array>
<string>com.example.app.view-item</string>
</array>Apple documents NSUserActivityTypes itself in one line: "The user activity types that the app supports." It has existed since iOS 8.0 and macOS 10.10. It is an array, so an app that hands off several kinds of state lists them all, and each string has to match a type its counterpart actually creates.
Document-based apps get an exemption
A document-based app that declares NSUbiquitousDocumentUserActivityType inside its CFBundleDocumentTypes entry does not have to repeat that type in NSUserActivityTypes.
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>NSUbiquitousDocumentUserActivityType</key>
<string>com.example.app.edit-doc</string>
</dict>
</array>That is the only exemption in this contract. Every other activity type your app expects to receive needs the array entry.
The gate is the Team ID and the type
This is the sentence worth memorising, and it comes from Apple's archived Handoff guide:
A user activity can be continued only in an app that has the same developer Team ID as the activity's source app and that supports the activity's type.
Apple, About Handoff (archived)
Two conditions, and the sentence names no third. There is no Handoff entitlement to request, no capability to switch on in Xcode, no review step and no per-app approval. If both apps are signed by the same team and the receiver declares the type, the gate is open; if either is untrue, it is shut. Most of the time a first Handoff fails because one of those two is untrue: the Mac target was never given the plist key, or a second team signs the Mac app.
Everything else that has to be true for a transfer to actually happen is environmental rather than contractual: the two devices signed into the same Apple Account, Handoff enabled, Bluetooth and Wi-Fi on. Apple Platform Security describes the transport for Handoff and requires the same Apple Account throughout; that page was last updated on 2021-02-18. The six-condition checklist that turns most "Handoff is broken" reports into a settings problem is part 9.
Advertising from SwiftUI
In a SwiftUI view you do not usually build the activity by hand. The .userActivity(_:element:_:) modifier hands you one to populate, tied to the view that is on screen. It has been available since iOS 14.0 and macOS 11.0.
struct ItemDetailView: View {
let item: Item
var body: some View {
ItemBody(item: item)
.userActivity(
ActivityType.viewItem,
element: item.id
) { id, activity in
activity.title = item.name
activity.userInfo = ["id": id]
activity.isEligibleForHandoff = true
}
}
}The element argument is the value the activity describes, so the closure re-runs when it changes. That is what you want when the reader moves from one item to the next without the view being torn down.
Apple's characterisation of the modifier's reach is one sentence: "The scope of the activity applies only to the scene or window the view is in." We will not claim more about its scheduling than Apple documents. What matters for a first Handoff is that the activity exists while the view is on screen, carries the agreed type, and is populated.
Receiving and restoring
On the other device, .onContinueUserActivity(_:perform:) is the paste-in happy path. It too has been available since iOS 14.0 and macOS 11.0.
@main
struct ExampleApp: App {
@State private var opened: String?
var body: some Scene {
WindowGroup {
RootView(openedItemID: $opened)
.onContinueUserActivity(
ActivityType.viewItem
) { activity in
guard let id = activity
.userInfo?["id"] as? String
else { return }
opened = id
}
}
}
}Three things about that closure. The type argument already filters, so there is no need to compare activity.activityType inside the body: if the closure runs, the type matched. The userInfo is typed [AnyHashable: Any]?, so every read is a cast that can fail, and the guard is not defensive theatre: the dictionary crossed a device boundary and you did not write the version of the app that sent it. And the work in the closure should restore state, not stage a new screen. The reader believes they are continuing, so the destination should look like where they already were.
What this version has not settled
The code above is a complete Handoff. It is also, on iOS, the version that makes an assumption the series is about to take apart. Do not ship it believing the routing question is closed.
The first open question is which seam actually receives the activity. Apple documents several delivery paths across UIKit, AppKit and SwiftUI, they do not all fire in the same app, and the app-delegate continuation method on iOS is not the safe default it looks like: since iOS 13, an app that declares a scene manifest gets its activity delivered to the scene delegate instead. The three UIApplicationDelegate Handoff methods also carry an availability upper bound of 26.0 in Apple's machine-readable headers, while the UISceneDelegate and NSApplicationDelegate equivalents carry none. That is part 3, including what AppKit does differently and why macOS is unaffected.
Worse for a reader wanting a rule: across every delivery-path page in our research pass, Apple never documents a precedence rule for what happens when both an app-delegate and a scene-delegate method are implemented. The widely held belief that scenes win is long-standing practice and community reporting, not an Apple sentence. There are also six forum threads from 2020 through 2025 reporting that .onContinueUserActivity never fires in a SwiftUI macOS app, with no Apple reply, no root cause and no workaround in any of them. That is community evidence about experience, which is enough to design defensively around and not enough to state as an API fact. Part 3 takes both seriously.
The second open question is smaller and sharper. Apple never states what happens if you omit NSUserActivityTypes. There is no documented error, no documented log line, no documented fallback. The consequence is only derivable from the continuation rule above: an app that does not declare the type does not support the type, so nothing continues into it. That is a sound inference and it is still an inference. We looked for the Apple sentence that says it outright, and it is not there.
And the payload is deliberately unexamined here. Apple does publish a figure of 3 KB, but only on one archived page last revised on 2016-04-01, never restated on the current reference, which ships a dedicated too-large error code with no number attached. Part 4 is that argument in full, including what userInfo is allowed to contain and why a payload should be refused rather than truncated.
What you can take from this part: the activity, the key, and the fact that the same Team ID plus a declared type is the entire admission gate. Everything after that is routing, and routing is where the interesting failures live.
Previously (Part 1): Apple Continuity Is Three Different Contracts
Next up (Part 3): Where Handoff Actually Arrives on iOS and macOSlands in 2 days
Comments
Loading comments…