前言
我们某些场景时常能看到这样的实时通知效果,这个是怎么做的也曾一度引起我的好奇,这次尝试实现一个简单的实时进度通知,同样末尾附上完整代码。
实时进度的原理
实时进度主要基于 iOS 16 引入的实时活动(Live Activity)功能实现。实时活动允许应用在锁屏界面和灵动岛上显示实时更新的信息,如体育赛事的比分、外卖的配送进度等。这些信息可以通过用户在应用中触发的事件来实时更新,也可以通过远程推送通知来更新。
限制
强大的功能背后同样会有很多限制
-
实时活动同灵动岛小组件一样需要 系统iOS 16.1及以上
-
使用了部分API如 pushType 或者 ActivityContent 需要 系统 iOS 16.1及以上
-
锁屏通知区域实时活动在 8小时 之内可以刷新数据展示,超过8小时 不再支持刷新,超过 12小时 强制消失
-
实时活动视图本体不支持发起网络请求或接收位置更新,所有的动态数据都要经由 ActivityKit 和 远程推送 刷新,且每次更新的数据不能超过 4KB
-
实时活动可以通过推送下发更新数据,但是推送的类型不同于传统 "基于证书 " 的推送,而是 "基于 token" 的推送类型。
灵动岛UI
UI效果为上面一个标题下面一个进度条,进度条可以实时更新进度。
代码在上次灵动岛的代码基础上改造,如果对灵动岛还没有了解可以参考我的【iOS小组件】灵动岛小组件
scss
struct MyWidgetLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: MyWidgetAttributes.self) { context in
// 实时通知
NotifityLiveActivityView(context: context)
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Text("Leading")
}
DynamicIslandExpandedRegion(.trailing) {
Text("Trailing")
}
DynamicIslandExpandedRegion(.bottom) {
Text("Bottom \(context.state.emoji)")
Text("内容:\(context.attributes.name) 自定义内容")
}
} compactLeading: {
Text("L")
} compactTrailing: {
Text("T \(context.state.emoji)")
} minimal: {
Text(context.state.emoji)
}
.widgetURL(URL(string: "http://www.apple.com"))
.keylineTint(Color.red)
}
}
}
实时通知UI
scss
struct NotifityLiveActivityView: View {
let context: ActivityViewContext<MyWidgetAttributes>
var body: some View {
VStack {
Spacer(minLength: 10)
HStack {
Text("\(context.state.emoji) \(context.state.title)")
}
Spacer(minLength: 0)
NotifityLiveActivityProgressView(progress: context.state.progress)
Spacer(minLength: 10)
}
}
}
进度条UI
scss
struct NotifityLiveActivityProgressView: View {
var progress: Float
let borderOffset = 20.0
var body: some View {
VStack {
Spacer(minLength: 0)
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 5)
.foregroundColor(Color.gray)
.frame(height: 10)
RoundedRectangle(cornerRadius: 5)
.foregroundColor(Color.yellow)
.frame(width: (UIScreen.main.bounds.width - borderOffset * 3) * Double(progress), height: 10)
}
.frame(height: 15)
.padding(.horizontal, borderOffset)
}
}
}
灵动岛数据配置
进行灵动岛数据前需要先导入 import ActivityKit
框架,灵动岛数据部分模型类为 ActivityAttributes ,自定义数据模型需要继承自 ActivityAttributes。
typescript
struct MyWidgetAttributes: ActivityAttributes {
// 动态数据,接收到推送时会更新的数据
public struct ContentState: Codable, Hashable {
var emoji: String
var title: String = "实时通知"
var progress: Float = 0
}
// 静态数据
var name: String
}
ActivityAttributes属性:
-
动态数据 :动态数据需要声明在 ContentState 结构体中,这部分变量在接收到推送更新数据时,会自动根据 json 数据的 key 值进行解析并更新
-
静态数据:静态数据变量直接在结构体内,初始化后将不会改变
Live Activity生命周期
Live Activity 的生命周期由 ActivityKit 管理,大致可以分为 创建 、更新 、结束 3个部分
1.创建
利用 Activity 的 request
方法创建,需要传入静态数据部分的 MyWidgetAttributes
.
name
及动态数据部分的 MyWidgetAttributes
.
ContentState
less
// 开始实时活动
func startActivity() {
// 静态数据
let attributes = MyWidgetAttributes(
name: "iOS 小溪"
)
// 动态数据
let initialContentState = MyWidgetAttributes.ContentState(
emoji: "😄",
title: "开始实时活动通知",
progress: 0
)
try? Activity.request(
attributes: attributes,
content: .init(state: initialContentState, staleDate: nil)
)
}
如果使用了系统 iOS 16.1 会出现如下报错,需要将系统最低兼容版本改为 iOS 16.2
2.更新
利用Activity 的**update
** 方法更新,需要传入动态数据部分的 MyWidgetAttributes.ContentState
swift
// 更新实时活动
func updateActivity(){
Task{
// 动态数据
let updatedStatus = MyWidgetAttributes.ContentState(
emoji: "😂",
title: "实时活动通知更新中",
progress: self.progress
)
let content = ActivityContent<MyWidgetAttributes.ContentState>(state: updatedStatus, staleDate: nil)
for activity in Activity<MyWidgetAttributes>.activities{
await activity.update(content)
print("更新成功,当前进度\(self.progress)")
}
}
}
3.结束
利用 Activity 的 end
方法结束,将灵动岛从锁屏通知界面上移除
swift
// 结束实时活动
func endActivity(){
self.progress = 0;
Task{
for activity in Activity<MyWidgetAttributes>.activities{
await activity.end(nil, dismissalPolicy: .immediate)
print("已关闭灵动岛显示")
}
}
}
效果
-
创建实时活动:点击【启动灵动岛 】->点击【更新灵动岛 】->【锁定屏幕】即可看到实时通知进度更新效果。
-
关闭实时活动:实时活动进度结束后可以回到应用点击【关闭灵动岛】按钮
完整代码
scss
// MyWidgetLiveActivity.swift
import SwiftUI
import WidgetKit
import ActivityKit
struct MyWidgetAttributes: ActivityAttributes {
// 动态数据,接收到推送时会更新的数据
public struct ContentState: Codable, Hashable {
var emoji: String
var title: String = "实时通知"
var progress: Float = 0
}
// 静态数据
var name: String
}
struct NotifityLiveActivityView: View {
let context: ActivityViewContext<MyWidgetAttributes>
var body: some View {
VStack {
Spacer(minLength: 10)
HStack {
Text("\(context.state.emoji) \(context.state.title)")
}
Spacer(minLength: 0)
NotifityLiveActivityProgressView(progress: context.state.progress)
Spacer(minLength: 10)
}
}
}
struct NotifityLiveActivityProgressView: View {
var progress: Float
let borderOffset = 20.0
var body: some View {
VStack {
Spacer(minLength: 0)
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 5)
.foregroundColor(Color.gray)
.frame(height: 10)
RoundedRectangle(cornerRadius: 5)
.foregroundColor(Color.yellow)
.frame(width: (UIScreen.main.bounds.width - borderOffset * 3) * Double(progress), height: 10)
}
.frame(height: 15)
.padding(.horizontal, borderOffset)
}
}
}
struct MyWidgetLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: MyWidgetAttributes.self) { context in
// 实时通知
NotifityLiveActivityView(context: context)
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Text("Leading")
}
DynamicIslandExpandedRegion(.trailing) {
Text("Trailing")
}
DynamicIslandExpandedRegion(.bottom) {
Text("Bottom \(context.state.emoji)")
Text("内容:\(context.attributes.name) 自定义内容")
}
} compactLeading: {
Text("L")
} compactTrailing: {
Text("T \(context.state.emoji)")
} minimal: {
Text(context.state.emoji)
}
.widgetURL(URL(string: "http://www.apple.com"))
.keylineTint(Color.red)
}
}
}
swift
// ViewController.swift
import SwiftUI
import ActivityKit
struct ViewController: View {
@State var progress: Float = 0.0;
@State var backgroundTask: UIBackgroundTaskIdentifier = .invalid
private func startBackgroundTask() {
backgroundTask = UIApplication.shared.beginBackgroundTask {
// 如果任务时间到了,系统会调用这个回调
self.endBackgroundTask()
}
}
private func endBackgroundTask() {
UIApplication.shared.endBackgroundTask(self.backgroundTask)
self.backgroundTask = .invalid
}
// 定时任务,后台任务保证进度的正常进行
private func tick() {
guard progress < 1.0 else {
endBackgroundTask()
return
}
// 开始后台任务
if backgroundTask == .invalid {
startBackgroundTask()
}
// 继续定时任务
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
self.updateActivity()
self.progress += 0.2 // 模拟进度增加
self.tick()
}
}
// 开始实时活动
func startActivity() {
// 静态数据
let attributes = MyWidgetAttributes(
name: "iOS 小溪"
)
// 动态数据
let initialContentState = MyWidgetAttributes.ContentState(
emoji: "😄",
title: "开始实时活动通知",
progress: 0
)
try? Activity.request(
attributes: attributes,
content: .init(state: initialContentState, staleDate: nil)
)
}
// 更新实时活动
func updateActivity(){
Task{
// 动态数据
let updatedStatus = MyWidgetAttributes.ContentState(
emoji: "😂",
title: "实时活动通知更新中",
progress: self.progress
)
let content = ActivityContent<MyWidgetAttributes.ContentState>(state: updatedStatus, staleDate: nil)
for activity in Activity<MyWidgetAttributes>.activities{
await activity.update(content)
print("更新成功,当前进度\(self.progress)")
}
}
}
// 结束实时活动
func endActivity(){
self.progress = 0;
Task{
for activity in Activity<MyWidgetAttributes>.activities{
await activity.end(nil, dismissalPolicy: .immediate)
print("已关闭灵动岛显示")
}
}
}
var body: some View {
VStack {
ButtonViewBuilder(title: "启动灵动岛", action: startActivity)
ButtonViewBuilder(title: "更新灵动岛", action: tick)
ButtonViewBuilder(title: "关闭灵动岛", action: endActivity)
}
.padding()
}
func ButtonViewBuilder(title: String, action: @escaping () -> Void) -> some View {
Button(title, action: action)
.buttonStyle(.bordered)
}
}
示例代码
github: github.com/MisterZhouZ...
参考
本文同步自微信公众号 "程序员小溪" ,这里只是同步,想看及时消息请移步我的公众号,不定时更新我的学习经验。