Live Activity - 创建你自己的灵动岛 App

swift 发布于 2023年02月03日

十一假期结束了,不知道大家假期如何。 继续和大家聊聊 iOS 16 的新特性。 这篇文章给大家介绍如何在自己的 iOS App 中实现 Dynamic Island 的功能, 也就是灵动岛。

Dynamic Island 和灵动岛都是一个含义,技术内容的特指名词我们尽量用英文原始词汇, 所以后面我们都用 Dynamic Island

另外,写这篇文章的时间点, 相关的 API 只可在 iOS 16.1 beta 版本使用, 所以,还请注意你是否安装了对应版本的开发环境。

Live Activity 简介

如果要实现自己的 Dynamic Island, 就必须通过 Live Activity 来调用。 直接翻译过来,它的意思就是实时活动。 比如我们使用播放器放一段音乐,就会在锁屏下方和 Dynamic Island 区域这两个地方显示它的状态:

锁屏下方:

Dynamic Island 区域:

只不过之前这个行为是 iOS 内置的,并且相关的UI 也都是固定的, 没有提供给我们接口去自定义它。

Live Activity 对应的 ActivityKit 就是 iOS 16.1 版本中引入的新库。

它的官方文档地址在这里:https://developer.apple.com/documentation/activitykit

实现步骤

首先,要保证你的开发环境是 iOS 16.1 版本。 我在写这篇文章时, Xcode 正式版本还是基于 iOS 16.0 的,这个版本的 Xcode 是无法使用相关 API 的。 这个问题折腾了挺长时间,后来发现是自己的开发环境不对。

我是直接去下载的 Xcode-beta 版本,根据你自己的情况,保证你的开发环境中能选择到 iOS 16.1 版本,才可以使用。

1. 设置 Info.plist

开发环境准备好之后, 创建一个 App 项目, 然后在 Info.plist 中加入一个新的条目 Supports Live Activities (或者使用原始Key: NSSupportsLiveActivities), 并且设置为 YES, 告诉系统你的 App 需要使用 Live Activity 的权限。 否则调用 API 的时候会直接报错(最新版本的 Xcode 才能找到这个选项)。

2. 创建 Widget Extension

接下来,我们还需要创建一个 Widget Extension,没错, Dynamic Island 的 UI 是基于 Widget 的:

然后在创建信息界面,勾选上 Include Live Activity 这个选项。 这样 Xcode 会给我们生成一个 Dynamic Island 的基础模版:

创建好之后, Widget 目录中会包含一个 IslandLiveActivity 文件,这个就是和我们 Dynamic Island 相关的内容了:

记得将这个文件的 Target Membership 勾选上我们的 App 项目,这样做 App 代码中就可以引用到 Widget 中的内容(这个在后面会用到):

我们简单来看一下 IslandLiveActivity 的代码, 它包含了两个 struct 的定义:

struct IslandAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
var value: Int
}
var name: String
}

struct IslandLiveActivity: Widget {
// Widget 代码暂时忽略...
}

IslandAttributesIslandLiveActivity 这两个定义, IslandAttributes 可以理解为 Dynamic Island 的数据部分, 它包含了一个 name 属性,和一个 ContentState 内部结构的定义:

public struct ContentState: Codable, Hashable {
var value: Int
}

在这里面又定义了一个 value 属性。 简单来说,IslandAttributes 直接的属性,比如我们这里的 name, 是静态属性,比如我们前面音乐播放器截图的音乐名称这种。

而定义在内部结构 ContentState 中的属性, 比如 value, 就是动态属性, 可以在展示的过程中进行修改,就比如说音乐播放器中的音乐播放进度。

3. 启动 Live Activity

现在,Dynamic Island 的前置准备都完成了, 我们只需要通过 Live Activity 开启它, 在 App 主项目中,引入 ActivityKit,然后调用它的Activity.request方法:

import UIKit
import ActivityKit

class ViewController: UIViewController {

override func viewDidLoad() {
super.viewDidLoad()

let state = IslandAttributes.ContentState(value: 2);
let attr = IslandAttributes(name: "test")

do {
try Activity.request(attributes: attr, contentState: state)
} catch {
print("err \(error)")
}
}
}

这里用到了 Widget 项目中的 IslandAttributes 结构, 我们给了它的 namevalue 属性分别指定了初始值,然后传入 Activity.request 方法中。 这样 Live Activity 就开启了,我们同时可以在锁屏界面,Dynamic Island区域中,看到相关的界面了。

这也是为什么我们要在前面将 IslandLiveActivity 这个文件的 Target 同时选到 App 和 Widget 项目中的原因,我们需要中 App 项目中引用这个文件中定义的结构 IslandAttributes

现在编译运行我们的 App 项目, viewDidLoad 就会被调用, 也就会开启 Live Activity 了,然后观察 Dynamic Island 区域和锁屏界面:

Dynamic Island 默认界面

Dynamic Island 展开界面

锁屏界面

UI 实现

从上面的截图我们也可以看出,Live Activity 的整体流程包含了 3个界面, 锁屏状态下,以及 Dynamic Island 的收缩和展开状态。现在回到刚才被我们跳过的 Widget 代码部分:

struct IslandLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: IslandAttributes.self) { context in
// Lock screen/banner UI goes here
VStack {
Text("Hello")
}
.activityBackgroundTint(Color.cyan)
.activitySystemActionForegroundColor(Color.black)

} dynamicIsland: { context in
DynamicIsland {
// Expanded UI goes here. Compose the expanded UI through
// various regions, like leading/trailing/center/bottom
DynamicIslandExpandedRegion(.leading) {
Text("Leading")
}
DynamicIslandExpandedRegion(.trailing) {
Text("Trailing")
}
DynamicIslandExpandedRegion(.bottom) {
Text("Bottom")
// more content
}
} compactLeading: {
Text("L")
} compactTrailing: {
Text("T")
} minimal: {
Text("Min")
}
.widgetURL(URL(string: "http://www.apple.com"))
.keylineTint(Color.red)
}
}
}

这里面定义了完整的 UI 内容。 ActivityConfiguration 第一个回调中定义的是锁屏状态下的 UI, 这里就是一个 Hello 文本, 前面锁屏的截图中也能看到:

ActivityConfiguration(for: IslandAttributes.self) { context in
// Lock screen/banner UI goes here
VStack {
Text("Hello")
}
.activityBackgroundTint(Color.cyan)
.activitySystemActionForegroundColor(Color.black)

}

dynamicIsland 回调中,自然就是 Dynamic Island 相关 UI 的定义了,首先使用了 DynamicIsland 这个 View 组件,然后它又分别定义了各个状态下的回调内容,我们只需要在相应的位置提供对应的 UI 就可以了。比如它第一个回调中,是展开状态下的 UI:

DynamicIsland {
// Expanded UI goes here. Compose the expanded UI through
// various regions, like leading/trailing/center/bottom
DynamicIslandExpandedRegion(.leading) {
Text("Leading")
}
DynamicIslandExpandedRegion(.trailing) {
Text("Trailing")
}
DynamicIslandExpandedRegion(.bottom) {
Text("Bottom")
// more content
}
}

其实从模版代码中的注释中,我们也不难看出, 展开状态下的 Dynamic Island 被划分出了几个区域, 比如我们这里用到的 .leading, .trailing, .bottom.

每个区域对应的位置,官方文档上有一个很明确的示意图:

基本上看这个就一目了然了。

接下来就是收缩状态下的 UI 内容了:

} compactLeading: {
Text("L")
} compactTrailing: {
Text("T")
}

这两个地方分别指定了收缩状态下,左右两边的 UI 内容,和我们之前的截图很好的对应上了。

还有最后一个:

} minimal: {
Text("Min")
}

这个状态是,同时如果有两个在运行的 Live Activity, 我们的组件在 Dynamic Island 可能会被收缩成一个最小的状态,比如这样:

这是 Apple 发布会上的一个演示,右边那个小圆形,就是 minimal 状态下的 UI 了。

总的来说,Dynamic Island 这几个状态下的 UI 都是必须要实现的。因为我们的 App 可能处于这些状态中任何一种。

明白了这个体系,也就知道如何设计应用的UI和交互标准了。

更新 Live Activity 的数据

Live Activity 的数据更新有两种方式,一个是通过 Push Notification,另外一个是调用 ActivityKit 方法。 如果我们的 App 当前没有运行在前台,就只能通过 Push Notification 的方式来更新了。

Push Notification 更新数据又是一个单独的主题了,这里我们只说一下在 App 中调用 ActivityKit 的方式进行更新。 首先我们要对 Widget 的 UI 稍作修改,让它能够使用 ContentState 中的数据

ActivityConfiguration(for: IslandAttributes.self) { context in
// Lock screen/banner UI goes here
VStack {
Text("Hello \(context.state.value)")
}
.activityBackgroundTint(Color.cyan)
.activitySystemActionForegroundColor(Color.black)

}

这里只对锁屏界面的 UI 做了修改,让它同时显示了 context.state.value 的内容。

然后我们对 App 端也进行一些修改:

var btnUpdate: UIButton?
var activity: Activity<IslandAttributes>?

// 启动 Activity
func startActivity() {

// 省略上下文...
self.activity = try Activity.request(attributes: attr, contentState: state)
// 省略上下文...
}

override func viewDidLoad() {

super.viewDidLoad()
self.startActivity()
self.addButton()
}

我们将 viewDidLoad 做了修改,将开启 Live Activity 的代码放到了 self.startActivity() 方法中, 并且这次将 这次用 Activity.request 调用的结果保存了下来,保存到 self.activity 属性中。

Activity.request 除了请求开启 Live Activity 之外,还会返回开启的 Activity 实例,我们保存下来后,可以用于后面的更新操作。

紧接着调用了 self.addButton() 方法添加了一个按钮, 这个按钮的点击事件绑定到 updateActivity() 方法:

func addButton() {

self.btnUpdate = UIButton(frame: CGRect(x: 50, y: 150, width: 100, height: 60))
self.btnUpdate?.backgroundColor = .blue
self.btnUpdate?.layer.cornerRadius = 15
self.btnUpdate?.setTitle("Update", for: .normal)
self.btnUpdate?.addTarget(self, action: #selector(self.updateActivity), for: .touchUpInside)
self.view.addSubview(self.btnUpdate!)

}

我们再来看一下 updateActivity():


// 更新 Activity
@objc func updateActivity() {

if let activity = self.activity {

let newState = IslandAttributes.ContentState(value: 3);

Task {

await activity.update(using: newState)

}

}
}

updateActivity() 方法里面, 首先获取到我们之前保存的 Activity 实例, 然后创建一个新的 ContentState 属性, 并且把它的 value 属性 设置为 3. 然后调用 activity.update 方法,将这个新的 ContentState 传进去。

当然,这里的 Taskawait 和业务逻辑无关, 是 Swift 关于异步方法的语言特性。 这点,我其实挺想吐槽下 Swift 近两年的情况的,感觉有点用力过猛了,不断的加入各种新的语言特性,让这门语言变得异常复杂。它每新加入一个关键词,就要花费大量的时间精力去了解,而且加入新特性的频率还不低。 你不去了解还不行,它很多新的 API 还会强制你使用。

毕竟我们大多数开发者用一门语言是要当作生产工具的,而不是炫技的。很多新特性的加入真有点多此一举的感觉,除了增加学习成本,没带来多少好处。

回到主题,这时候再运行 App, 然后点击 Update 按钮之后,你就可以看到锁屏界面的内容发生变化了:

结束 Live Activity

最后,我们还需要在适当的时候结束 Live Activity 的展示, 使用 activity.end 方法就可以了:

@objc func stopActivity() {

if let activity = self.activity {

let finalState = IslandAttributes.ContentState(value: 4);

Task {
await activity.end(using:finalState, dismissalPolicy: .default)
}
}
}

调用这个方法,我们依然要给他传递一个 ContentState 值。 那么大家可能会疑问了,都结束了, 为什么还要传一个新的状态值过去。 这是因为我们调用 activity.end 方法后,锁屏界面中的 Widget 不会马上消失,可能会稍微停留一点时间,这时候就会用到我们传进去的最终状态值了。

限制条件

最后说一下 Live Activity 的限制条件。

1 - 锁屏状态下的 Widget 视图,最大高度 160pt, 如果你的 UI 超过这个限制,会被截断。

2 - 通过推送或者调用 activity.update 的方式更新数据,每次发送的数据总量不能超过 4KB。

3 - 当前版本的 Live Activity 只能在 iPhone 设备上使用, 其他设备不行。我感觉以后可能会改。

4 - 用户可以手动在系统设置中禁止某个 App的 Live Activity 权限。 所以要在调用 Activity.request 之前先判断好我们有没有权限使用它,可以通过 ActivityAuthorizationInfo.areActivitiesEnabled 来直接获得, 也可以通过 ActivityEnablementUpdates 来持续监听这个属性的改动。 他们同时代表用户是否禁止 Live Activity 权限,以及当前设备是否支持 Live Activity 显示(当前来说,只有 iPhone 设备才支持)。

总结

从我们前面讲的这么多不难看出, Dynamic Island 并不是一个单独的概念存在。 它是基于 Live Activity 体系的一部分。 即便不支持 Dynamic Island 的设备,也可以使用 Live Activity, 只不过这些设备少了 Dynamic Island 的交互能力。但依然可以通过锁屏 Widget 的方式进行交互。

以我目前对官方文档的认知,这个能力在 iOS 16.1 的设备上才会开放,所以普遍用户的话还要等一段时间才能体验到。至于它能做什么,大家就可以发挥自己的想象了。

我把官方文档的地址也给大家贴出来:https://developer.apple.com/documentation/activitykit/displaying-live-data-with-live-activities

我对这个文档的观感是,写的有点仓促,细节地方,特别是代码上下文有不少不明确的地方,我也是边看文档边摸索才弄清楚整体脉络。

这篇文章主要是把 Live ActivityDynamic Island 的整体流程和大家做个介绍,主要是帮大家更快的了解这套 API 能做什么,很多细节并没有展开,后面如果有时间再帮大家继续完善细节。

如果觉得对你有帮助,还请转发收藏吧:)


如果你觉得这篇文章有帮助,还可以关注微信公众号 swift-cafe,会有更多我的原创内容分享给你~

本站文章均为原创内容,如需转载请注明出处,谢谢。
关注微信公众号
发现更多精彩
swift-cafe