在 SwiftUI 中使用 NSUserActivity

chewy-EV9_vVMZTcg-unsplash

欢迎关注微信公众号「Swift 花园」

译自 https://swiftui-lab.com/nsuseractivity-with-swiftui/

译者注:作者为了扩展他的 SwiftUI 应用去研究了 NSUserActivity,发现关于 NSUserActivity 的大量信息都已经过时了。比如,绝大多数关于 Handoff 的文章都是 Handoff 特性刚有的时候发布的,而那个时候还没有 scene 的概念,所有逻辑都是通过 application 的 delegate 处理的。之后 scene 出现了,许多代码被移到了 scene delegate,原来的 Handoff 示例也就失效了。如果你是刚开始学习 NSUserActivity,一定会感到困惑,而现在 SwiftUI 也支持用户活动,但现在又没有 scene 了,变化更大,所以作者认为需要写一篇新的文档来介绍 SwiftUI 中 NSUserActivity 的使用。

NSUserActivity 令人费解的另一个原因是它是一个可以用来处理多个不相干功能的东西。它的各项属性只在某些情况下相关,多数情况下却是没有关联的。

下面是有关 NSUserActivity 的一些总结:

  • Universal Links: Universal links 是可以在关联应用或者 Safari 中打开的 URL。
  • SiriKit: Siri 可以调起你的应用并且告知你它想要做什么。
  • Spotlight: 定义你的应用可以做的动作,这些动作会被引入 Spotlight 的搜索结果中。
  • Handoff: 即 “接力”,指一个应用可以继续另一个应用的工作,或者一台设备上的相同应用可以继续另一个设备上的应用的工作。

这篇文档会提供一系列示例,逐步介绍 SwiftUI 中提供的用于处理 NSUserActivity 的方法,其中上面提到的每一种情况都会有示例。

重要的笔记

SwiftUI 中跟 NSUserActivity 有关的方法包括:onOpenURL (), userActivity (), onContinueUserActivity ()handlesExternalEvents ()。注意,这些方法只有当你的应用采用的是 SwiftUI 应用生命周期时才能工作。如果你的项目还是使用 scene delegate,引入这几个方法会在控制台输出下面的消息:

1
2
3
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.

个人经验,上面的消息中提到的不可预测的结果,实际上完全可以预测:所有这些方法都将被忽略。

User Activity 的两面

根据 Apple 的 官方文档,一个用户活动(user activity)对象代表了某个应用在某个时刻的状态:

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.

某个 NSUserActivity 对象提供一种捕捉你的应用状态的轻量级方式。你创建 user activity 对象,并用它们来捕获用户正在做的事情的信息,比如查看应用内容,编辑文档,阅览网页,或者观看视频。当系统启动你的应用时,如果活动对象可用,你的应用可以利用对象中的信息把应用还原成合适的状态。

理解了概念,我们就可以区分用户活动中的两个关键时刻:其一,用户活动创建(稍后说明何时、如何创建);其二,系统决定启动或者恢复某个应用,并且为应用提供一个 NSUserActivity,以便应用展示相关的 UI。我们接下来会学习如何在应用中对用户活动做出反应。

注意,尽管一个应用可以有多个 scene,但某个时刻只有一个 scene 会获得用户活动。在本文中我们还将了解到获取用户活动的 scene 是如何被确定的…


介绍 onOpenURL ()

Universal Links 对于把应用集成到网站十分有用。建立 Universal Links 需要几个步骤,Apple 为其提供了详细的文档:Universal Links

在 SwiftUI 中使用 NSUserActivity 的所有用法中,Universal Links 是最容易实现的。尽管 Universal Links 本质上是使用 NSUserActivity 来启动或者恢复你的应用,但假如你的应用是走 SwiftUI 应用生命周期,你却根本看不到 NSUserActivity 的踪影!

在 UIKit 里实现 Universal Links ,一般是在 scene delegate 里这么做:

1
2
3
4
5
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
if userActivity.activityType == NSUserActivityTypeBrowsingWeb {
doSomethingWith (url: userActivity.webpageURL)
}
}

但现在没有了 scene delegate,我们只需要简单地使用 onOpenURL 方法,它会得到 URL 对象,而不是 NSUserActivity 对象:

1
2
3
4
5
6
7
8
9
10
11
12
struct ContentView: View {
var body: some View {
SomeView()
.onOpenURL { url in
doSomethingWith (url: url)
}
}

func doSomethingWith(url: URL?) {
...
}
}

SiriKit

介绍 onContinueUserActivity ()

我们可以给一个应用中的特定部分定义快捷指令。在 iOS 中,这个动作可以借助 “快捷指令” 应用来实现,但我们也可以在应用内通过代码实现。UIKit 有一些专门的 UI 元素来处理这件事,但 SwiftUI 中并没有直接可用的方法,所以这一节中的例子会包含一个 UIViewControllerRepresentable,它的作用是提供一个按钮,点击这个按钮可以打开系统的模态表单,让用户创建或者编辑快捷指令。

一旦快捷指令创建,我们就可以调用 Siri 指令来执行它。它会启动或者恢复我们的应用,并且通过 NSUserActivity 提供用户希望我们执行的快捷指令的细节信息。SwiftUI 对此为我们提供了一个 onContinueUserActivity ()

在下面的例子中,通过指令 “Hey Siri, show random animal” (或者某些其它的预置指令),系统会启动我们的应用并导航到某个随机的动物视图。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
import SwiftUI
import Intents

// 要记得把下面的声明添加到 Info.plist 文件的 NSUserActivityTypes 数组中
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()
}
}
}

下面是用于创建快捷指令和编辑模态表单的 UIViewControllerRepresentable

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
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() {
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)
}
}

Spotlight

介绍 userActivity ()

Spotlight 的搜索结果可以包含应用中的通用活动。为了让 Spotlight 学会你的活动,你需要在这些活动出现时对外发布,以便 Spotlight 发现它们。要在 SwiftUI 中发布 NSUserActivities,我们需要使用 userActivity () modifier。

在下面的例子中,我们有一个售卖冰淇淋的应用。每当我们选择了某个冰淇淋尺寸,应用将对外发布冰淇淋尺寸;每当有用户搜索冰淇淋时,我们的应用将出现在搜索结果中。如果用户选择了我们的应用对于的搜索结果项,我们的应用将被调起,并且将用户带到最后公布的冰淇淋尺寸。

注意,系统会优化 userActivity () 闭包的调用时机。然而不幸的是,这一点并没有文档说明。系统很聪明,知道该如何保存当前的信息,避免不停更新。在调试的时候,建议你最好在 userActivity 闭包中加入打印语句。

下面的例子中还包含了一个 “Forget” 按钮,这对于调试十分有帮助。它会清除掉已经发布的用户活动,以便将应用从 Spotlight 的搜索结果中移除。注意,NSUserActivity 有一个可选属性:expirationDate,如果将其置为 nil,则活动永远不会过期。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
import SwiftUI
import Intents
import CoreSpotlight
import CoreServices

// 要记得把下面的声明添加到 Info.plist 文件的 NSUserActivityTypes 数组中
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.2f", 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.2f", 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)")
}
}
}

Handoff - 接力

基于已经介绍的方法,我们可以创建一个接力应用了。这将是一个可以在另外的设备上接力工作的应用。两个设备上的应用可以是同一个应用或者不同的应用。这种情况通常发生在我们分发了应用的两个不同版本的时候:比如一个是 iOS 版本,另一个是 macOS 版本。

为了让接力能够工作,相关的应用都需要注册在同一个开发者团队的标识下面,并且在所有参与的应用的 Info.plist 中配置 NSUserActivityTypes 实体。

有关于接力的更多实现细节,可以参考 Apple 的 网站

下面的示例实现了一个简单的 web 浏览器,通过调用 userActivity () 发布用户正在浏览的页面以及他在页面上滚动的位置。

假如用户切换到了另一个设备,当同一个应用被调起或者恢复时,onContinueUserActivity () 闭包将被调用,应用可以借此打开同一个页面,并且滚动到他在之前设备上浏览到的页面位置。

用户活动可以通过 userInfo 字典的形式提供负载数据,它是我们存放特定于接力活动信息的地方。在这个示例中,即页面滚动位置(百分比)和所打开页面的 URL。此外,还包含了发布此活动的应用的 bundle id,它只是用来调试的信息,便于我们准确地知道发生的事情。

注意,示例的代码在 iOS 和 macOS 上都能工作,这是为了让你能够同时创建两个应用,并且测试 iOS 设备和 Mac 设备之间的接力。

最后,虽然跟 NSUserActivity 无关,这个示例还封装了一个 WKWebView,这是演示 javascript 事件(这个示例中的 onScroll)如何更新你的 SwiftUI 视图绑定的绝佳示例。完整的 WebView 代码可以从下面的 gist 文件找到:WebView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
import SwiftUI

// 要记得把下面的声明添加到 Info.plist 文件的 NSUserActivityTypes 数组中
let activityType = "com.example.openpage"

struct ContentView: View {
@StateObject var data = WebViewData()

@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
}
}

Scene 选择

介绍 handlesExternalEvents ()

当系统启动或者恢复我们的应用的时候,它必须确定哪个 scene 能够接受到用户活动(某一个时刻只有一个能接收到)。为了帮助它做出这个决策,我们的应用可以用上 handlesExternalEvents () 方法。不幸运是,写这篇文档的时候 (Xcode 12, beta 6),这个方法貌似不起作用,而且 macOS 上虽然有支持,但缺少平台定义文件。

所以我这里将通过注释来说明它的工作方式,等将来真正可用时,我会更新这篇文章。

这个方法有两个版本。一个用于 WindowGroup 场景:

1
func handlesExternalEvents(matching conditions: Set<String>) -> some Scene

另一个用于视图:

1
func handlesExternalEvents(preferring: Set<String>, allowing: Set<String>) -> some View

两种版本中我们都会指定一个字符串 Set,系统用它来跟 NSUserActivity 的 targetContentIdentifier 属性做比对。假如找到匹配项,对应的 scene 会被选用。如果不指定这个 Set,或者没有找到匹配项,则实际行为由具体平台决定。例如,在 iPadOS 上会选择一个已经现有的 scene,而在 macOS 上,新的 scene 会被创建。

在只支持一个 scene 的系统上,这个方法将被忽略。

注意,targetContentIdentifierUNNotificationContentUIApplicationShortcutItem 中也有提供,因此 handlesExternalEvents () 大概率也会支持它们。

总结

Apple 关于 NSUserActivity 的文档资料非常多,所以我建议你去查阅这些文档。但目前 缺少 SwiftUI 的示例。这篇文章的目的就是为你提供一些在 SwiftUI 中实践 NSUserActivity 的启动代码。


封面来自 Chewy on Unsplash

Linkedin
Plus
Share
Class
Send
Send
Pin