文章

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 进行授权