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