Translation swiftui-lab.com/nsuseractiv…
The author researched NSUserActivity to extend his SwiftUI app and found that a lot of information about NSUserActivity was out of date. For example, most articles about Handoff were published in the early days of the Handoff feature, before the concept of scene existed and all logic was handled through the application delegate. Then the scene appears, a lot of code is moved to the Scene delegate, and the original Handoff example is dead. If you are just starting to learn NSUserActivity, you will definitely feel confused, and now SwiftUI also supports user activities, but now there is no scene, and the change is bigger. Therefore, the author thinks that a new document is needed to introduce the use of NSUserActivity in SwiftUI.
Another reason NSUserActivity is confusing is that it is something that can be used to handle multiple unrelated functions. Its properties are only related in some cases, but not in most cases.
Here are some summaries of NSUserActivity:
- Universal Links: Universal Links are urls that can be opened in the associated app or Safari.
- SiriKit: Siri calls up your app and tells you what it wants to do.
- Spotlight: Define the actions your app can take and those actions will be included in Spotlight search results.
- Handoff: Relay, where one application can continue the work of another application, or the same application on one device can continue the work of another application on another device.
This document provides a series of examples that walk you through the methods provided in SwiftUI for handling NSUserActivity, including examples for each of the cases mentioned above.
Important notes
SwiftUI nsuserActivit-related methods include onOpenURL(), userActivity(), onContinueUserActivity(), and handlesExternalEvents(). Note that these methods only work if your application uses the SwiftUI application lifecycle. If your project still uses scene Delegate, introducing these methods will output the following message on the console:
Cannot use Scene methods for URL, NSUserActivity, and other External Events without using SwiftUI Lifecycle. Without SwiftUI Lifecycle, advertising and handling External Events wastes resources, and will have unpredictable results.
Copy the code
From personal experience, the unpredictable results mentioned in the above message are actually entirely predictable: all of these methods will be ignored.
Both sides of User Activity
According to Apple’s official documentation, a User activity object represents the state of an app at a point in time:
An NSUserActivity object provides a lightweight way to capture the state of your app and put it to use later. You create user activity objects and use them to capture information about what the user was doing, such as viewing app content, editing a document, viewing a web page, or watching a video. When the system launches your app and an activity object is available, your app can use the information in that object to restore itself to an appropriate state.
An NSUserActivity object provides a lightweight way to capture the state of your application. You create User Activity objects and use them to capture information about what the user is doing, such as viewing app content, editing documents, viewing web pages, or watching videos. When the system starts your application, if the live object is available, your application can use the information in the object to restore the application to the appropriate state.
With this understanding, we can distinguish between two key moments in user activity: first, user activity creation (when and how will be explained later); Second, the system decides to start or resume an application and provides an NSUserActivity for the application to display the associated UI. We’ll learn how to react to user activity in the application.
Note that although an application can have multiple scenes, only one scene will get user activity at any one time. In this article we’ll also learn how to determine the scene of a user’s activity…
Universal Links
Introduce onOpenURL ()
Universal Links are useful for integrating applications into websites. Setting up Universal Links takes several steps, and Apple provides detailed documentation for it: Universal Links.
Of all the uses of NSUserActivity in SwiftUI, Universal Links is the easiest to implement. Although Universal Links essentially uses NSUserActivity to start or resume your app, if your app is SwiftUI app life cycle, you won’t see NSUserActivity at all!
To implement Universal Links in UIKit, you usually do this in a Scene Delegate:
func scene(_ scene: UIScene.continue userActivity: NSUserActivity) {
if userActivity.activityType = = NSUserActivityTypeBrowsingWeb {
doSomethingWith(url: userActivity.webpageURL)
}
}
Copy the code
But now that there’s no scene delegate, we simply use the onOpenURL method, which gets the URL object instead of the NSUserActivity object:
struct ContentView: View {
var body: some View {
SomeView()
.onOpenURL { url in
doSomethingWith(url: url)
}
}
func doSomethingWith(url: URL?). {
.}}Copy the code
SiriKit
Introduce onContinueUserActivity ()
We can define shortcut instructions for specific parts of an application. On iOS, this can be done with the quick Commands app, but it can also be done in app code. UIKit has some special UI elements to deal with this matter, but did not directly available in SwiftUI method, so the examples in this section will contain a UIViewControllerRepresentable, its role is to provide a button, Clicking this button opens the system’s modal form, allowing the user to create or edit shortcut commands.
Once the shortcut command is created, we can invoke the Siri command to execute it. It will start or resume our application and provide details of the shortcut instructions the user wants us to perform via NSUserActivity. SwiftUI provides us with an onContinueUserActivity() for this
In the example below, with the command “Hey Siri, show random animal” (or some other preset command), the system launches our app and navigates to a random animal view.
import SwiftUI
import Intents
// Remember to add the following declaration to the NSUserActivityTypes array in the info.plist file
let aType = "com.example.show-animal"
struct Animal: Identifiable {
let id: Int
let name: String
let image: String
}
let animals = [Animal(id: 1, name: "Lion", image: "lion"),
Animal(id: 2, name: "Fox", image: "fox"),
Animal(id: 3, name: "Panda", image: "panda-bear"),
Animal(id: 4, name: "Elephant", image: "elephant")]
struct ContentView: View {
@State private var selection: Int? = nil
var body: some View {
NavigationView {
List(animals) { animal in
NavigationLink(
destination: AnimalDetail(animal: animal),
tag: animal.id,
selection: $selection,
label: { AnimalRow(animal: animal) })
}
.navigationTitle("Animal Gallery")
.onContinueUserActivity(aType, perform: { userActivity in
self.selection = Int.random(in: 0.(animals.count - 1))
})
}.navigationViewStyle(StackNavigationViewStyle()}}struct AnimalRow: View {
let animal: Animal
var body: some View {
HStack {
Image(animal.image)
.resizable()
.frame(width: 60, height: 60)
Text(animal.name)
}
}
}
struct AnimalDetail: View {
@State private var showAddToSiri: Bool = false
let animal: Animal
let shortcut: INShortcut = {
let activity = NSUserActivity(activityType: aType)
activity.title = "Display a random animal"
activity.suggestedInvocationPhrase = "Show Random Animal"
return INShortcut(userActivity: activity)
}()
var body: some View {
VStack(spacing: 20) {
Text(animal.name)
.font(.title)
Image(animal.image)
.resizable()
.scaledToFit()
SiriButton(shortcut: shortcut).frame(height: 34)
Spacer()}}}Copy the code
Below is used to create the quick instructions and edit mode to form UIViewControllerRepresentable:
import SwiftUI
import IntentsUI
struct SiriButton: UIViewControllerRepresentable {
public let shortcut: INShortcut
func makeUIViewController(context: Context) -> SiriUIViewController {
return SiriUIViewController(shortcut: shortcut)
}
func updateUIViewController(_ uiViewController: SiriUIViewController.context: Context){}}class SiriUIViewController: UIViewController {
let shortcut: INShortcut
init(shortcut: INShortcut) {
self.shortcut = shortcut
super.init(nibName: nil, bundle: nil)}required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")}override func viewDidLoad(a) {
super.viewDidLoad()
let button = INUIAddVoiceShortcutButton(style: .blackOutline)
button.shortcut = shortcut
self.view.addSubview(button)
view.centerXAnchor.constraint(equalTo: button.centerXAnchor).isActive = true
view.centerYAnchor.constraint(equalTo: button.centerYAnchor).isActive = true
button.translatesAutoresizingMaskIntoConstraints = false
button.delegate = self}}extension SiriUIViewController: INUIAddVoiceShortcutButtonDelegate {
func present(_ addVoiceShortcutViewController: INUIAddVoiceShortcutViewController.for addVoiceShortcutButton: INUIAddVoiceShortcutButton) {
addVoiceShortcutViewController.delegate = self
addVoiceShortcutViewController.modalPresentationStyle = .formSheet
present(addVoiceShortcutViewController, animated: true)}func present(_ editVoiceShortcutViewController: INUIEditVoiceShortcutViewController.for addVoiceShortcutButton: INUIAddVoiceShortcutButton) {
editVoiceShortcutViewController.delegate = self
editVoiceShortcutViewController.modalPresentationStyle = .formSheet
present(editVoiceShortcutViewController, animated: true)}}extension SiriUIViewController: INUIAddVoiceShortcutViewControllerDelegate {
func addVoiceShortcutViewController(_ controller: INUIAddVoiceShortcutViewController.didFinishWith voiceShortcut: INVoiceShortcut? .error: Error?). {
controller.dismiss(animated: true)}func addVoiceShortcutViewControllerDidCancel(_ controller: INUIAddVoiceShortcutViewController) {
controller.dismiss(animated: true)}}extension SiriUIViewController: INUIEditVoiceShortcutViewControllerDelegate {
func editVoiceShortcutViewController(_ controller: INUIEditVoiceShortcutViewController.didUpdate voiceShortcut: INVoiceShortcut? .error: Error?). {
controller.dismiss(animated: true)}func editVoiceShortcutViewController(_ controller: INUIEditVoiceShortcutViewController.didDeleteVoiceShortcutWithIdentifier deletedVoiceShortcutIdentifier: UUID) {
controller.dismiss(animated: true)}func editVoiceShortcutViewControllerDidCancel(_ controller: INUIEditVoiceShortcutViewController) {
controller.dismiss(animated: true)}}Copy the code
Spotlight
Introduce userActivity ()
Spotlight’s search results can include common activities in your app. In order for Spotlight to learn about your activities, you need to publish them as they come up so Spotlight can find them. To publish NSUserActivities in SwiftUI, we need to use the userActivity() modifier.
In the example below, we have an application that sells ice cream. Every time we select an ice cream size, the app will publish the ice cream size; Every time a user searches for ice cream, our app will show up in the search results. If the user selects the search results for our app, our app will be called up and take the user to the last published ice cream size.
Note that the system optimizes the call timing of the userActivity() closure. Unfortunately, there is no documentation for this. The system is smart enough to know how to keep current information and avoid constant updates. When debugging, it is recommended that you include print statements in the userActivity closure.
The following example also includes a “Forget” button, which is useful for debugging. It purges published user activity to remove the app from Spotlight’s search results. Note that NSUserActivity has an optional property: expirationDate, which will never expire if you set it to nil.
import SwiftUI
import Intents
import CoreSpotlight
import CoreServices
// Remember to add the following declaration to the NSUserActivityTypes array in the info.plist file
let aType = "com.example.icecream-selection"
struct IceCreamSize: Identifiable {
let id: Int
let name: String
let price: Float
let image: String
}
let sizes = [
IceCreamSize(id: 1, name: "Small", price: 1.0, image: "small"),
IceCreamSize(id: 2, name: "Medium", price: 1.45, image: "medium"),
IceCreamSize(id: 3, name: "Large", price: 1.9, image: "large")]struct ContentView: View {
@State private var selection: Int? = nil
var body: some View {
NavigationView {
List(sizes) { size in
NavigationLink(destination: IceCreamDetail(icecream: size),
tag: size.id,
selection: $selection,
label: { IceCreamRow(icecream: size) })
}
.navigationTitle("Ice Creams")
.toolbar {
Button("Forget") {
NSUserActivity.deleteAllSavedUserActivities {
print("done!")
}
}
}
}
.onContinueUserActivity(aType, perform: { userActivity in
if let icecreamId = userActivity.userInfo?["sizeId"] as? NSNumber {
selection = icecreamId.intValue
}
})
.navigationViewStyle(StackNavigationViewStyle()}}struct IceCreamRow: View {
let icecream: IceCreamSize
var body: some View {
HStack {
Image(icecream.image)
.resizable()
.frame(width: 80, height: 80)
VStack(alignment: .leading) {
Text("\(icecream.name)").font(.title).fontWeight(.bold)
Text("$ \(String(format: "% 0.2 f", icecream.price))").font(.subheadline)
Spacer()}}}}struct IceCreamDetail: View {
let icecream: IceCreamSize
var body: some View {
VStack {
Text("\(icecream.name)").font(.title).fontWeight(.bold)
Text("$ \(String(format: "% 0.2 f", icecream.price))").font(.subheadline)
Image(icecream.image)
.resizable()
.scaledToFit()
Spacer()
}
.userActivity(aType) { userActivity in
userActivity.isEligibleForSearch = true
userActivity.title = "\(icecream.name) Ice Cream"
userActivity.userInfo = ["sizeId": icecream.id]
let attributes = CSSearchableItemAttributeSet(itemContentType: kUTTypeItem as String)
attributes.contentDescription = "Get a delicious ice cream now!"
attributes.thumbnailData = UIImage(named: icecream.image)?.pngData()
userActivity.contentAttributeSet = attributes
print("Advertising: \(icecream.name)")}}}Copy the code
Handoff – relay
Based on the method already described, we can create a relay application. This will be an application that can relay work on other devices. Applications on two devices can be the same application or different applications. This usually happens when we distribute two different versions of the app: for example, an iOS version and a macOS version.
In order for the relay to work, the related applications need to be registered under the same developer team identity and the NSUserActivityTypes entity configured in the info.plist for all participating applications.
For more implementation details on relay, see Apple’s web site.
The following example implements a simple Web browser that publishes the page the user is viewing and where he scrolls on the page by calling userActivity().
If the user switches to another device, the onContinueUserActivity() closure is called when the same application is invoked or resumed, allowing the application to open the same page and scroll to the same page it was browsing to on the previous device.
User activities can provide payload data in the form of the userInfo dictionary, which is where we store relay-specific activity information. In this case, the page scroll position (percent) and the URL of the page opened. In addition, it contains the bundle ID of the application publishing this activity, which is just debugging information so that we know exactly what is happening.
Note that the sample code works on both iOS and macOS so that you can create two apps at the same time and test the relay between iOS and Mac devices.
Finally, although unrelated to NSUserActivity, this example also wraps a WKWebView, which is a great example of how javascript events (onScroll in this case) can update your SwiftUI view binding. The complete WebView code can be found in the gist file below: webView.swift
import SwiftUI
// Remember to add the following declaration to the NSUserActivityTypes array in the info.plist file
let activityType = "com.example.openpage"
struct ContentView: View {
@StateObject var data = WebViewData(a)@State private var reload: Bool = false
var body: some View {
VStack {
HStack {
TextField("", text: $data.urlBar, onCommit: { self.loadUrl(data.urlBar) })
.textFieldStyle(RoundedBorderTextFieldStyle())
.disableAutocorrection(true)
.modifier(KeyboardModifier())
.frame(maxWidth: .infinity)
.overlay(ProgressView().opacity(self.data.loading ? 1 : 0).scaleEffect(0.5), alignment: .trailing)
Button(action: {
self.data.scrollOnLoad = self.data.scrollPercent
self.reload.toggle()
}, label: { Image(systemName: "arrow.clockwise")})Button("Go") {
self.loadUrl(data.urlBar)
}
}
.padding(.horizontal, 4)
Text("\(data.scrollPercent)")
WebView(data: data)
.id(reload)
.onAppear { loadUrl(data.urlBar) }
}
.userActivity(activityType, element: data.url) { url, activity in
let bundleid = Bundle.main.bundleIdentifier ?? ""
activity.addUserInfoEntries(from: ["scrollPercent": data.scrollPercent,
"page": data.url?.absoluteString ?? ""."setby": bundleid])
logUserActivity(activity, label: "activity")
}
.onContinueUserActivity(activityType, perform: { userActivity in
if let page = userActivity.userInfo?["page"] as? String {
// Load handoff page
if self.data.url?.absoluteString ! = page {
self.data.url = URL(string: page)
}
// Restore handoff scroll position
if let scrollPercent = userActivity.userInfo?["scrollPercent"] as? Float {
self.data.scrollOnLoad = scrollPercent
}
}
logUserActivity(userActivity, label: "on activity")})}func loadUrl(_ string: String) {
if string.hasPrefix("http") {
self.data.url = URL(string: string)
} else {
self.data.url = URL(string: "https://" + string)
}
self.data.urlBar = self.data.url?.absoluteString ?? string
}
}
func logUserActivity(_ activity: NSUserActivity.label: String = "") {
print("\(label) TYPE = \(activity.activityType)")
print("\(label) INFO = \(activity.userInfo ?? [:])")}struct KeyboardModifier: ViewModifier {
func body(content: Content) -> some View {
#if os(iOS)
return content
.keyboardType(.URL)
.textContentType(.URL)
#else
return content
#endif}}Copy the code
Scene selection
Introduce handlesExternalEvents ()
When the system starts or resumes our application, it must determine which scene can receive user activity (only one at a time). To help it make this decision, our application can use the handlesExternalEvents() method. Unfortunately, as of this writing (Xcode 12, Beta 6), this method doesn’t seem to work, and while supported on macOS, the platform definition file is missing.
So I’ll annotate how it works here, and I’ll update this post when it becomes available in the future.
There are two versions of this method. One for WindowGroup scenarios:
func handlesExternalEvents(matching conditions: Set<String>) -> some Scene
Copy the code
The other is for views:
func handlesExternalEvents(preferring: Set<String>, allowing: Set<String>) -> some View
Copy the code
In both versions we specify a string Set that the system uses to compare NSUserActivity’s targetContentIdentifier property. If a match is found, the corresponding scene is selected. If the Set is not specified, or no match is found, the actual behavior is determined by the platform. For example, on iPadOS an existing scene is selected, while on macOS a new scene is created.
On systems that support only one scene, this method will be ignored.
Note that in targetContentIdentifier UNNotificationContent and UIApplicationShortcutItem is usually served, So handlesExternalEvents() will most likely support them as well.
conclusion
Apple’s documentation for NSUserActivity is extensive, so I encourage you to check it out. However, the SwiftUI example is currently missing. The purpose of this article is to provide you with some startup code for implementing NSUserActivity in SwiftUI.
Cover from Chewy on Unsplash