Highlight
ActivityKit 让开发者可以用 SwiftUI 和 WidgetKit 在锁屏、灵动岛和 StandBy 上创建实时活动,支持本地更新和远程推送更新,iOS 17 还新增了对 iPad 和交互式控件的支持。
核心内容
用户想随时知道外卖到哪了、比赛比分如何、打车还有多久到。以前这些信息要么需要打开 app 查看,要么靠频繁推送通知打扰用户。
Live Activity 解决这个问题的方式是在锁屏和灵动岛上持续显示关键信息。它有明确的开始和结束,实时更新进度,用户扫一眼就能获得所需信息。
在 iPhone 14 Pro 和 Pro Max 上,Live Activity 会出现在灵动岛。单个活动时显示 compact 形态,两个活动时都变成 minimal 形态。长按可以展开 expanded 形态查看更多信息。
iOS 17 带来了三个新能力:StandBy 模式下 Live Activity 会放大显示;iPad 也支持了 Live Activity;可以用 WidgetKit 添加按钮、切换等交互控件。
详细内容
定义 ActivityAttributes
(05:40)每个 Live Activity 需要定义静态和动态数据:
import ActivityKit
struct AdventureAttributes: ActivityAttributes {
let hero: EmojiRanger // 静态数据:创建后不变
struct ContentState: Codable & Hashable {
let currentHealthLevel: Double // 动态数据:会更新
let eventDescription: String
}
}
关键点:
ActivityAttributes协议定义静态属性- 嵌套的
ContentState定义动态状态,必须实现Codable和Hashable - 静态数据在创建时确定,动态数据可以实时更新
启动 Live Activity
(06:28)在 app 前台启动 Live Activity:
let adventure = AdventureAttributes(hero: hero)
let initialState = AdventureAttributes.ContentState(
currentHealthLevel: hero.healthLevel,
eventDescription: "Adventure has begun!"
)
let content = ActivityContent(
state: initialState,
staleDate: nil,
relevanceScore: 0.0
)
let activity = try Activity.request(
attributes: adventure,
content: content,
pushType: nil // nil 表示仅本地更新
)
关键点:
- 只能在 app 前台请求 Live Activity
- 应该在用户明确操作后启动(如”开始追踪”)
staleDate标记内容何时过期relevanceScore决定多个 Live Activity 的排序,越高越靠前pushType: nil表示只通过本地代码更新,设为.token可接收远程推送
更新 Live Activity
(08:00)状态变化时调用 update:
let heroName = activity.attributes.hero.name
let contentState = AdventureAttributes.ContentState(
currentHealthLevel: hero.healthLevel,
eventDescription: "\(heroName) has taken a critical hit!"
)
var alertConfig = AlertConfiguration(
title: "\(heroName) has taken a critical hit!",
body: "Open the app and use a potion to heal \(heroName)",
sound: .default
)
activity.update(
ActivityContent<AdventureAttributes.ContentState>(
state: contentState,
staleDate: nil
),
alertConfiguration: alertConfig
)
关键点:
update传入新的ContentState和可选的AlertConfiguration- Alert 在 Apple Watch 上显示为通知,在 iPhone/iPad 上播放指定声音
- 标题和 body 只在 Apple Watch 上使用
监听 Activity 状态
(09:30)观察状态变化以便清理资源:
// 异步监听
func observeActivity(activity: Activity<AdventureAttributes>) {
Task {
for await activityState in activity.activityStateUpdates {
if activityState == .dismissed {
self.cleanUpDismissedActivity()
}
}
}
}
// 同步获取
let activityState = activity.activityState
if activityState == .dismissed {
self.cleanUpDismissedActivity()
}
四种状态:
.started— 已启动.finished— 已结束.dismissed— 用户已关闭.stale— 内容已过期
结束 Live Activity
(10:03)任务完成时结束活动:
let hero = activity.attributes.hero
let finalContent = AdventureAttributes.ContentState(
currentHealthLevel: hero.healthLevel,
eventDescription: "Adventure over! \(hero.name) has defeated the boss!"
)
let dismissalPolicy: ActivityUIDismissalPolicy = .default
activity.end(
ActivityContent(state: finalContent, staleDate: nil),
dismissalPolicy: dismissalPolicy
)
关键点:
.default策略让结束后的内容在锁屏保留一段时间- 可以传入最终状态给用户一个总结
添加 Live Activity 到 WidgetBundle
(10:50)Live Activity 配置需要加入 widget extension:
import WidgetKit
import SwiftUI
@main
struct EmojiRangersWidgetBundle: WidgetBundle {
var body: some Widget {
EmojiRangerWidget()
LeaderboardWidget()
AdventureActivityConfiguration()
}
}
定义锁屏界面
(11:05)锁屏和 StandBy 共用一套 UI:
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
// 灵动岛配置...
}
}
}
关键点:
ActivityConfiguration接收 attributes 类型和闭包context提供attributes、state、isStale- 不需要指定尺寸,系统自动决定
- StandBy 会自动放大锁屏 UI 填满屏幕
定义灵动岛 Compact 形态
(13:28)单个活动时的 compact 形态:
dynamicIsland: { context in
DynamicIsland {
// expanded 形态...
} 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: {
// minimal 形态...
}
}
关键点:
compactLeading和compactTrailing分别位于灵动岛两侧- 空间有限,只放最关键的信息
- 用户应该能通过这些内容识别出是哪个活动
定义灵动岛 Minimal 形态
(14:42)两个活动同时存在时的 minimal 形态:
minimal: {
ProgressView(value: context.state.currentHealthLevel) {
Avatar(hero: context.attributes.hero)
}
.progressViewStyle(.circular)
.tint(context.state.currentHealthLevel <= 0.2 ? Color.red : Color.green)
}
关键点:
- minimal 形态空间极小,只能放最关键的信息
- 一个活动附着在 TrueDepth 相机旁,另一个 detached
定义灵动岛 Expanded 形态
(15:26)长按展开后的 expanded 形态:
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
LiveActivityAvatarView(hero: hero)
}
DynamicIslandExpandedRegion(.trailing) {
StatsView(hero: hero, isStale: isStale)
}
DynamicIslandExpandedRegion(.bottom) {
HealthBar(currentHealthLevel: contentState.currentHealthLevel)
EventDescriptionView(hero: hero, contentState: contentState)
}
} compactLeading: {
// ...
} compactTrailing: {
// ...
} minimal: {
// ...
}
关键点:
- expanded 分为
.leading、.trailing、.bottom三个区域 .center区域留给系统显示时间- 视图可以添加 deep link,点击跳转到 app 对应页面
核心启发
1. 外卖/配送进度追踪
- 做什么:用户下单后在锁屏和灵动岛实时显示骑手位置和预计送达时间
- 为什么值得做:用户不需要反复打开 app 查看进度,体验提升明显
- 怎么开始:定义
DeliveryAttributes包含订单号(静态)和骑手位置、ETA(动态),用ActivityConfiguration设计简洁的进度条 UI
2. 运动/健身实时数据
- 做什么:跑步、骑行时在灵动岛显示配速、心率和距离
- 为什么值得做:运动时看手机不方便,灵动岛一瞥就能获得关键数据
- 怎么开始:静态数据放运动类型和目标,动态数据放实时配速和距离,compact 形态显示当前配速,expanded 显示完整数据面板
3. 比赛/赛事比分直播
- 做什么:关注一场球赛,在锁屏持续显示比分和关键事件
- 为什么值得做:比推送通知更持续、更不打扰,用户随时扫一眼就知道比分
- 怎么开始:结合远程推送更新(参考 10185 session),服务器推送进球、红黄牌等事件,本地更新 UI
4. 打车/网约车等待
- 做什么:叫车后显示车辆距离、车牌号和预计到达时间
- 为什么值得做:解决用户反复打开 app 看车到哪了的痛点
- 怎么开始:静态数据放目的地和车型,动态数据放车辆位置和 ETA,minimal 形态只显示预计分钟数
关联 Session
- Update Live Activities with push notifications — 远程推送更新 Live Activity
- Bring widgets to life — iOS 17 WidgetKit 交互性
- Design dynamic Live Activities — Live Activity 设计最佳实践
评论
GitHub Issues · utterances