iOS 小组件 - 灵动岛开发
灵动岛 (Dynamic Island) 是 iPhone 14 Pro 系列推出. 最低适配系统 iOS 16.1
在开发中灵动岛相关开发称为 ‘实时活动’(Live Activity)
Live Activity overView
- Activity framework
- Programatic layout with SwiftUI and WidgetKit
- Explicit user action to begin a Live Activity
- Must support Lock Scrren and the Dynamic Island
- Update remotely useing push notifications
Live Activity 的生命周期
实时活动生命周期共4个阶段, 如下:
- Request
- Update
- Observe activity state
- End
请求
实时活动的请求必须在 APP 前台,并对其进行配置, 这样就能获得活动的初始化数据.
定义实时活动属性数据(动态静态数据).
1
2
3
4
5
6
7
8
9
10
import ActivityKit
struct AdventureAttributes: ActivityAttributes {
let hero: EmojiRanger
struct ContentState: Codable & Hashable {
let currentHealthLevel: Double
let eventDescription: String
}
}
通过初始化状态请求实时活动
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let adventure = AdventureAttributes(hero: hero)
let initialState = AdventureAttributes.ContentState(
currentHealthLevel: hero.healthLevel,
eventDescription: "Adventure has begun!"
)
// 实时活动内容: 初始状态, 结束时间为 nil, 即暂时不设置失效时间, 相关性得分表示多个活动展示的优先级
let content = ActivityContent(state: initialState, staleDate: nil, relevanceScore: 0.0)
// 请求
let activity = try Activity.request(
attributes: adventure,
content: content,
pushType: nil // 仅支持本地通知样式
)
更新
使用新内容更新实时活动, 可以通过后台App运行时更新
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 创建新内容
let heroName = activity.attributes.hero.name
let contentState = AdventureAttributes.ContentState(
currentHealthLevel: hero.healthLevel,
eventDescription: "\(heroName) has taken a critical hit!"
)
// 配置 alert 样式
var alertConfig = AlertConfiguration(
title: "\(heroName) has taken a critical hit!",
body: "Open the app and use a potion to heal \(heroName)",
sound: .default
)
// 更新 activity
activity.update(
ActivityContent<AdventureAttributes.ContentState>(
state: contentState,
staleDate: nil
),
alertConfiguration: alertConfig
)
观察活动状态
活动状态可能随时发生变化, 相关 api activity.activityStateUpdates
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Observe activity state asynchronously
func observeActivity(activity: Activity<AdventureAttributes>) {
Task {
for await activityState in activity.activityStateUpdates {
if activityState == .dismissed {
self.cleanUpDismissedActivity()
}
}
}
}
// Observe activity state synchronously
let activityState = activity.activityState
if activityState == .dismissed {
self.cleanUpDismissedActivity()
}
关闭活动
1
2
3
4
5
6
7
8
9
10
11
12
13
let hero = activity.attributes.hero
let finalContent = AdventureAttributes.ContentState(
currentHealthLevel: hero.healthLevel,
eventDescription: "Adventure over! \(hero.name) has defeated the boss! Congrats!"
)
let dismissalPolicy: ActivityUIDismissalPolicy = .default
activity.end(
ActivityContent(state: finalContent, staleDate: nil),
dismissalPolicy: dismissalPolicy)
}
构建实时活动 UI
在 widgetBundle 中添加实时活动, 实时活动也是采用了 widget 的配置.(可将其理解为一个特殊 widget)
1
2
3
4
5
6
7
8
9
10
11
import WidgetKit
import SwiftUI
@main
struct EmojiRangersWidgetBundle: WidgetBundle {
var body: some Widget {
EmojiRangerWidget()
LeaderboardWidget()
AdventureActivityConfiguration() // 实时活动 widget
}
}
定义锁屏和灵动岛展开的表现(必须实现)
第一个回调是锁屏, 第二个是灵动岛, 灵动岛本身又分为紧凑型/极简型/拓展型
1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct AdventureActivityConfiguration: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: AdventureAttributes.self) { context in
AdventureLiveActivityView(
hero: context.attributes.hero,
isStale: context.isStale,
contentState: context.state
)
.activityBackgroundTint(Color.navyBlue)
} dynamicIsland: { context in
// ...
}
}
}
灵动岛的紧凑型展示(当仅有当前App自己的活动的时候), 紧凑型分为 leading / trailing 两部分, 共同构成了一个完整的独立的活动事件.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct AdventureActivityConfiguration: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: AdventureAttributes.self) { context in
// ...
} dynamicIsland: { context in
DynamicIsland {
// ...
} compactLeading: {
Avatar(hero: context.attributes.hero)
} compactTrailing: {
ProgressView(value: context.state.currentHealthLevel) {
Text("\(Int(context.state.currentHealthLevel * 100))")
}
.progressViewStyle(.circular)
.tint(context.state.currentHealthLevel <= 0.2 ? Color.red : Color.green)
} minimal: {
// ...
}
}
}
}
极简型活动布局. 极简型是有多个实时活动时候, 系统会展示两个实时活动, 当前被展示的会被放在 leading 位置, 空间极为有限
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct AdventureActivityConfiguration: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: AdventureAttributes.self) { context in
// ...
} dynamicIsland: { context in
DynamicIsland {
// ...
} compactLeading: {
// ...
} compactTrailing: {
// ...
} minimal: {
ProgressView(value: context.state.currentHealthLevel) {
Avatar(hero: context.attributes.hero)
}
.progressViewStyle(.circular)
.tint(context.state.currentHealthLevel <= 0.2 ? Color.red : Color.green)
}
}
}
}
拓展型 UI 展示和布局, 当紧凑型和极简型号活动被点击的时候,系统会展示拓展型,显示更多内容. 拓展型 UI 又被分割为多个区域, leading, trailing, bottom, center
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
struct AdventureActivityConfiguration: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: AdventureAttributes.self) { context in
// ...
} dynamicIsland: { context in
DynamicIsland {
// Leading region
DynamicIslandExpandedRegion(.leading) {
LiveActivityAvatarView(hero: hero)
}
// Expanded region
DynamicIslandExpandedRegion(.trailing) {
StatsView(hero: hero, isStale: isStale)
}
// Bottom region
DynamicIslandExpandedRegion(.bottom) {
HealthBar(currentHealthLevel: contentState.currentHealthLevel)
EventDescriptionView(hero: hero, contentState: contentState)
}
} compactLeading: {
// ...
} compactTrailing: {
// ...
} minimal: {
// ...
}
}
}
}
Live Activity Ui 的设计建议
- 只展示最重要的内容
- 简洁的设计
- 具体的详细信息进入 app 去展示
完整 Demo
请参考完整能运行的 DEMO
本文由作者按照 CC BY 4.0 进行授权