【iOS小组件实战】灵动岛实时进度通知

前言


我们某些场景时常能看到这样的实时通知效果,这个是怎么做的也曾一度引起我的好奇,这次尝试实现一个简单的实时进度通知,同样末尾附上完整代码。

实时进度的原理


实时进度主要基于 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.创建

利用 Activityrequest 方法创建,需要传入静态数据部分的 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.结束

利用 Activityend 方法结束,将灵动岛从锁屏通知界面上移除

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...

参考

本文同步自微信公众号 "程序员小溪" ,这里只是同步,想看及时消息请移步我的公众号,不定时更新我的学习经验。

相关推荐
良技漫谈1 天前
Rust移动开发:Rust在iOS端集成使用介绍
后端·程序人生·ios·rust·objective-c·swift
KeithTsui2 天前
ZFC in LEAN 之 前集的等价关系(Equivalence on Pre-set)详解
开发语言·其他·算法·binder·swift
袁代码2 天前
Swift 开发教程系列 - 第4章:函数与闭包
ios·swift·ios开发
今天也想MK代码2 天前
在Swift开发中简化应用程序发布与权限管理的解决方案——SparkleEasy
前端·javascript·chrome·macos·electron·swiftui
安泽13143 天前
高德地图美食
开发语言·swift·美食
袁代码3 天前
Swift 开发教程系列 - 第2章:Swift 基础语法
swift·ios开发·基础教程
袁代码3 天前
Swift 开发教程系列 - 第1章:Swift 简介与开发环境配置
swift·ios开发·基础教程
孚亭4 天前
一些swift问题
swift
莫问alicia4 天前
echarts 实现3D饼状图 加 label标签显示
前端·3d·echarts·swift
uiop_uiop_uiop6 天前
iOS Swift5算法恢复——HMAC
ios·iphone·swift