iOS 灵动岛的实现(Live activity)

首先,我们先简单了解下灵动岛


紧凑视图

拓展视图
锁屏视图

Live Activities 依赖于 Widget 实现 函数和页面,而与Widget不同,Live Activities无法访问网络或接收位置更新,更新Live Activities可以使用ActivityKit和远程推送,同时ActivityKit可以控制Live Activities的开始,更新和结束。

灵动岛一共有三种样式展示:

  1. 只有一个Live Activities活动时,如下图,将在灵动岛的左右两个部分显示信息(紧凑级),点击打开App查看详细信息


    image
  2. 而同时有多个Live Activities活动时,系统最多展示只两个(最小级)Live Activities活动,一个将紧贴灵动岛,一个单独展示在圆圈内,如下图:


    image.jpg
  3. 手指按中其中任何一个,系统将展示(拓展视图),如下图:


    image.jpeg

    灵动岛拓展区域划分见下图:


    image.jpeg

下面是手把手实现灵动岛功能

1. 创建Live Activities,如下图步骤
image.jpeg

image.jpg

image.jpeg
2. 完成创建Live Activities后,在主项目的Info.plist中添加NSSupportsLiveActivities = YES。
image.jpg
3. 下一步,根据[ActivityAttributes] 结构代码定义Live Activities所需的静态和动态数据,如图,在主项目创建:
image
4. 根据刚才创建的[ActivityAttributes]来创建灵动岛页面,如下代码:
import ActivityKit
import WidgetKit
import SwiftUI

//灵动岛界面配置
struct UavLiveActivity: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: UavAttributes.self) { context in
            // 创建显示在锁定屏幕上的演示,并在不支持动态岛的设备的主屏幕上作为横幅。
            HStack(alignment: .center) {
                Image("uav-logo")
                    .frame(width: 53,height: 59)
                if context.state.driverStatus == "1"{
                    VStack(alignment:.leading){
                        Text("物流备货中")
                            .font(.title3)
                            .fontWeight(.bold)
                        Text("请耐心等待…")
                            .font(.callout)
                            .foregroundColor(Color(red: 102/255, green: 102/255, blue: 102/255))
                    }.padding(.leading,20)
                    Spacer()
                    Image("uav-wait")
                        .frame(width: 30,height: 30)
                }else if context.state.driverStatus == "2"{
                    VStack(alignment:.leading){
                        Text("已备货,等待配送")
                            .font(.title3)
                            .fontWeight(.bold)
                        Text("请耐心等待…")
                            .font(.callout)
                            .foregroundColor(Color(red: 102/255, green: 102/255, blue: 102/255))
                    }.padding(.leading,20)
                    Spacer()
                    Image("uav-wait")
                        .frame(width: 30,height: 30)
                }else if context.state.driverStatus == "3"{
                    
                    
                    VStack(alignment: .leading) {
                        
                        HStack{
                            Text("飞手配送中")
                                .font(.title3)
                                .fontWeight(.bold)
                            Spacer()
                            Text("距离目的地")
                                .font(.title3)
                            +
                            Text(context.state.distance)
                                .font(.title3)
                            +
                            Text("m")
                                .font(.title3)
                            
                        }
                        
                        HStack(alignment: .center){
                            
                            let prog = context.state.progress/100
                            GeometryReader { geometry in
                                
                                Image("uav-line")
                                    .resizable()
                                    .offset(x:0, y:1)
                                    .frame(width: geometry.size.width , height:4)
                                
                                RoundedRectangle(cornerRadius: 15)
                                    .fill(.blue)
                                    .padding(.trailing, geometry.size.width*(CGFloat(1 - prog)))
                                Image("uav-icon")
                                    .resizable()
                                    .frame(width:30, height:30)
                                    .padding(.leading,geometry.size.width*CGFloat(prog))
                                    .offset(x: -8, y:-12)
                                
                            }
                            .frame(height: 6)
                            Image("uav-ship")
                                .resizable()
                                .frame(width:30, height:30)
                            
                        }
                        
                    }
                    
                }else if context.state.driverStatus == "4"{
                    VStack(alignment:.leading){
                        Text("商品已送达")
                            .font(.title3)
                            .fontWeight(.bold)
                        Text("欢迎使用,祝你生活愉快!")
                            .font(.callout)
                            .foregroundColor(Color(red: 102/255, green: 102/255, blue: 102/255))
                    }.padding(.leading,20)
                    Spacer()
                    Image("uav-complete")
                        .frame(width: 30,height: 30)
                }
                
            }
            .padding(20)
            .widgetURL(URL(string: "cjh-my"))
        } dynamicIsland: { context in
            // 创建显示在动态岛中的内容。
            DynamicIsland {
                //这里创建拓展内容(长按灵动岛)
                DynamicIslandExpandedRegion(.leading) {
                    if context.state.driverStatus == "1"{
                        VStack{
                            Image("uav-smlogo")
                            Text("物流备货中")
                        }.padding(.leading,10)
                    }else if context.state.driverStatus == "2"{
                        VStack{
                            Image("uav-smlogo")
                            Text("已备货,等待配送")
                        }.padding(.leading,10)
                    }else if context.state.driverStatus == "3"{
                        VStack{
                            Image("uav-smlogo")
                            Text("飞手配送中")
                        }.padding(.leading,10)
                    }else{
                        VStack{
                            Image("uav-smlogo")
                            Text("商品已送达")
                        }.padding(.leading,10)
                    }
                    
                }
                DynamicIslandExpandedRegion(.trailing) {
                    
                    if context.state.driverStatus == "1"{
                        VStack{
                            Image("uav-smwait")
                            Spacer()
                            Text("请耐心等待...")
                        }.padding(.trailing,10)
                    }else if context.state.driverStatus == "2"{
                        VStack{
                            Image("uav-smwait")
                            Spacer()
                            Text("请耐心等待...")
                        }.padding(.trailing,10)
                    }else if context.state.driverStatus == "3"{
                        VStack{
                            Image("uav-smicon")
                            Spacer()
                            Text("距离")
                                .font(.title3)
                            +
                            Text(context.state.distance)
                                .font(.title3)
                            +
                            Text("m")
                                .font(.title3)
                        }.padding(.trailing,10)
                    }else{
                        VStack{
                            Image("uav-smcomplete")
                            Spacer()
                            Text("祝您生活愉快")
                        }.padding(.trailing,10)
                    }
                    
                }
                DynamicIslandExpandedRegion(.center) {
                }
                DynamicIslandExpandedRegion(.bottom) {
                }
            }
            //下面是紧凑展示内容区(只展示一个时的视图)
        compactLeading: {
            Image("uav-smlogo")
                .resizable()
                .frame(height:25)
                .padding(.leading,10)
        } compactTrailing: {
            if context.state.driverStatus == "1"{
                Image("uav-smwait")
                    .padding(.trailing,10)
            }else if context.state.driverStatus == "2"{
                Image("uav-smwait")
                    .padding(.trailing,10)
            }else if context.state.driverStatus == "3"{
                Image("uav-smicon")
                    .padding(.trailing,10)
            }else{
                Image("uav-smcomplete")
                    .padding(.trailing,10)
            }
            
        }
            //当多个Live Activities处于活动时,展示此处极小视图
        minimal: {
            VStack(alignment: .center) {
                Image(systemName: "timer")
            }
        }
        .keylineTint(.accentColor)
        .widgetURL(URL(string: "cjh-my"))
        }
    }   
}

灵动岛页面需要实现的部分有4个

不支持灵动岛的机型 或 锁屏时的 显示
紧凑级展示(即左右贴合灵动岛的展示)
多Live activity时的展示(即极小视图,左贴合,右分离)
拓展视图(长按时触发)

5. 完成上面步骤,Live activity 已经创建好了,下面是启动:

(下面代码包含和极光推送交互的代码,通过激光推送更新数据,把liveactivityId发送极光推送)

import UIKit
Import ActivityKit
import SwiftUI


class UavManage: NSObject {
    
    
    @available(iOS 16.1,*)
    @objc func startDeliveryAction(liveactivityId:String) {
        //初始化静态数据
        let uavAttributes = UavAttributes()
        //初始化动态数据
        
        let initialContentState = UavAttributes.UavStatus(distance: "10", driverStatus: "3", progress: 50)
        
        do {
            //启用灵动岛
            //灵动岛只支持Iphone,areActivitiesEnabled用来判断设备是否支持,即便是不支持的设备,依旧可以提供不支持的样式展示
            if ActivityAuthorizationInfo().areActivities Enabled == true{
                
            }
            let deliveryActivity = try Activity<UavAttributes>.request(
                attributes: uavAttributes,
                contentState: initialContentState,
                pushType: PushType.token)
            
            Task {
                // 监听 push token 更新
                to await pushToken in deliveryActivity.pushTokenUpdates {
                    let pushtokenstr = pushToken.map { String(format: "%02.2hhx", arguments: [$0]) }.joined()
                    // 向极光注册liveactivity,在推送平台通过liveactivityId推送通知进行更新
                    JPUSHService.registerLiveActivity(liveactivityId, pushToken: pushToken, completion: { code, liveactivityId, token, seq in
                        
                        print("registerLiveActivity liveactivityId:\(String(describing: liveactivityId)) token: \(pushtokenstr) result:\(code) seq:\(seq)")

                    }, seq: 1)
                    
                    let paraDic:[String:String] = ["token":pushtokenstr,"alias":liveactivityId]
                    
                    HttpUtil.post(withUrl: "activeUavsDelivery", withReq:paraDic) { succeedResult in
                        
                        if succeedResult?["code"] as! String == "0000" {
                            
                        }
                        
                    } failedBlock: { task, error in
                        
                    }
                }
                
            }
            
        } catch (let error) {
            print("Error info -> \(error.localizedDescription)")
        }
    }
}
灵动岛的活动状态一共有3种:
  1. active 处于活动中
  2. ended 已经终止且不会有任何更新,但依旧在锁屏界面展示
  3. dismissed 结束且不再展示
6. 更新灵动岛数据(更新的就是在ActivityAttributes中声明的动态数据):

下面的图片我是用别人的,本项目通过消息推送更新数据的

func updateDeliveryPizza(){
    Task {
        let updatedDeliveryStatus =
        PizzaDeliveryAttributes.PizzaDeliveryStatus (driverName:"快递小哥" deliveryTimer: Date()... Date() .addingTimeInterval (60 * 60))
        
        //此处只有一个灵动岛,当一个项目有多个灵动岛时,需要判断更新对应的activity
        for activity in Activity<PizzaDeliveryAttributes>.activitiest{
            await activity.update(using: updatedDeliveryStatus)
        }
        
    }
}
Live activity也支持远程推送更新,根据文档以下9点要求实现(Activity远程通知每小时有通知预算<数量未明确>,超出后系统将关闭通知)
1. 确保主程序已经开通了远程推送功能
2. 确保启动activity时[request(attributes:contentState:pushType:)传入pushType参数(.token)
3. 获取启动后的activity的推送令牌pushToken,传给服务端用来推送更新activity
4. 服务端推送的更新内容字段需要和ActivityAttributes的ContentState 中定义的动态数据字段对应
5. 设置推送的报头apns-push-type的值为liveactivity
6. 设置推送的报头apns-topic的值为<your bundleID>.push-type.liveactivity
7. 正确的推送对应的内容和状态
8. 使用pushTokenUpdates监听pushToken变化,如有变化,就令牌失效,需要将新的令牌传给服务器
9. 当Activity结束时,服务器端的pushToken将失效

下面是官方提供的示例:


  { 

  "aps": { 

  "timestamp": 1168364460, 

  "event": "update", 

  "content-state": { 

  "driverName": "Anne Johnson", 

  "estimatedDeliveryTime": 1659416400

  }, 

  "alert": { 

  "title": "Delivery Update", 

  "body": "Your pizza order will arrive soon.", 

  "sound": "example.aiff"

  } 

  } 

  }

可以在aps内设置dissall -date 字段来告诉系统在什么时候移除activity ,eg: “dismissal-date”: 1663177260

不用为推送提供声音 , 如果推送延迟,在activity结束后收到时将被忽略,avtivity每小时有通知预算(数量未明确),超出后系统将关闭通知

7. 结束Activity:
func stopDeliveryPizza () {
    Task {
        for activity in Activity<PizzaDeliveryAttributes>.activitiest{
            await activitv. end(dismissalPolicv: . immediate)
        }
    }
}

结束分为两种:
.default 系统默认,结束后在锁屏界面保留4小时
.immediate 立即结束,不会在锁屏界面停留

下面是通过极光推送实现的方式,实时活动数据的更新和结束
  1. 消息内容:添加的是动态数据,结束和更新相比会多一个实时活动结束的时间;
  2. 目标群体:是之前通过代码给极光推送注册的liveactivityId;
  3. 下面的图是手动推送的方式,后面主要通过后台和极光推送交互进行推送;
  4. 有个注意点是模拟器不会产生临时令牌,最终还是通过真机进行测试。
image

参考文档:https://www.jianshu.com/p/10541a43010c
官方文档:https://developer.apple.com/documentation/activitykit/starting-and-updating-live-activities-with-activitykit-push-notifications#Construct-the-ActivityKit-push-notification-payload
参考代码:https://github.com/jiajun1203/LiveActivities?tab=readme-ov-file

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,923评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,154评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,775评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,960评论 1 290
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,976评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,972评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,893评论 3 416
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,709评论 0 271
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,159评论 1 308
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,400评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,552评论 1 346
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,265评论 5 341
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,876评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,528评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,701评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,552评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,451评论 2 352

推荐阅读更多精彩内容