iOS灵动岛开发实践

一、根本概述

名词基础知识

苹果在 iPhone 14 Pro 系列中增加一个灵动岛,主要目的是隐藏挖孔造型的高端“感叹号屏”。 经过动画的视觉差异,用户找不到原来的挖孔屏。灵动岛是一种巧妙的规划,模糊了软件和硬件之间的界限,它可以在锁屏的状况下根据不同的使用程序操作和提示、告诉和活动内容的需求,自动改动大小和形状,展现用户关注的工作。

iOS灵动岛开发实践

iOS灵动岛开发实践

开发基础知识

1、设备只支持iPhone,而且是有“药丸屏”的iPhone14Pro和14Pro Max上;

2、Max系统版本、编译器及iOS系统版本:>=MacOS12.4、>=Xcode14.0+beta4、>=iOS16.1+beta;

3、运用 ActivityKit 用于装备、开端、更新、完毕完结 Live Activity 才能。运用 WidgetKitSwiftUIwidget 小组件中创立 Live Activity 的用户界面,这样小组件和 Live Activity 的代码是可以同享;

4、Live Activity 目前只能经过 ActivityKit 从主工程获取数据,或许从 长途告诉 获取最新数据;无法访问网络或许接受方位更新信息

5、 ActivityKit 和 长途告诉推送 更新的数据不能超过4KB;

6、Live Activity 可以给不同的控制绑定不同的 deeplink,使其跳转到不同的页面;

7、Live Activity 在用户自动完毕前最多存活8小时;

8、现已完毕的 Live Activity 在锁屏也最多保存4小时,所以一个 Live Activity 最长可以停留12小时;

9、最多同时存在两组 Live Activity ,排列次序待发现

二、项目构思

这儿以电商常用的抢购产品作为实践,包括的交互方式主要有如下特征:

1、主工程产品能展现根本信息,包括图片、称号、价格、开抢时间、预定按钮等;

2、主工程记录产品预定状况及灵动岛的使命状况,防止重复预定;

3、敞开预定后,在打开App的状况下,灵动岛看到该产品的根本信息,支持当即抢购;

包括了开发灵动岛的基础知识:

1、主工程数据经过 ActivityKit 来启动、更新、停止灵动岛 Widget;

2、灵动岛 Widget 的根本布局方式及开发注意事项;

3、灵动岛 Widget 点击跳转到主工程的事情通讯;

三、效果展现

四、代码实践

1、创立主工程及灵动岛Widget工程

iOS灵动岛开发实践

iOS灵动岛开发实践

在主工程的 info.plist 文件中增加 Supports Live Activities 而且设置为 YES

2、熟悉 ActivityKit 常用对象及API,创立根本视图

数据部分(主工程):承继 ActivityAttributes ,定义常用的数据来控制或改动UI及状况。这儿包括的产品的根本信息,可以认为是不变的状况,可变的状况需要在 ContentState 中声明。

// SeckillProduct.swift
import Foundation
import ActivityKit
struct SeckillProductAttributes: ActivityAttributes {
    typealias SeckillProductState = ContentState
    public struct ContentState: Codable, Hashable {
	      // 可变的特点需要放在这儿,activity调用update进行数据的更新
        var isSeckill: Bool
    }
    // 一个灵动岛使命的初始化数据,描绘一些不可变数据
    let productId: String
    let name: String
    let price: String
    let image: String
    let countDown: Double
    let isSeckill: Bool
    init(productId: String, name: String,  price: String, image: String, countDown: Double, isSeckill: Bool = false) {
        self.productId = productId
        self.name = name
        self.price = price
        self.image = image
        self.countDown = countDown
        self.isSeckill = isSeckill
    }
}

灵动岛布局(Widget工程):承继 Widget ,经过 ActivityConfiguration 来管理锁屏及灵动岛的布局。

这儿包括了如何从主工程获取数据展现及将点击事情传递给主工程的代码示例:

// HDSeckillAvtivityLiveActivity.swift
import ActivityKit
import WidgetKit
import SwiftUI
struct HDSeckillAvtivityLiveActivity: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: SeckillProductAttributes.self) { context in
            // 锁屏之后,显现的桌面告诉栏方位,这儿可以做相对杂乱的布局
            VStack {
                Text("Hello").multilineTextAlignment(.center)
            }
            .activityBackgroundTint(Color.cyan)
            .activitySystemActionForegroundColor(Color.black)
        } dynamicIsland: { context in
            // 灵动岛的布局代码
            DynamicIsland {
                /*
                 这儿是长按灵动岛区域后打开的UI
                 有四个区域约束了布局,分别是左、右、中间(硬件下方)、底部区域
                 这儿采取左面为App的Icon、右边为上下布局(产品称号+产品图标)、
                 中间为当即购买按钮,支持点击deeplink传参引发App、
                 底部为价格和倒计时区域
                 */
                DynamicIslandExpandedRegion(.leading) {
                    Image("zyg100").resizable().frame(width: 32, height: 32)
                }
                DynamicIslandExpandedRegion(.trailing) {
                    Text(context.attributes.name).font(.subheadline).multilineTextAlignment(.center)
                    Spacer(minLength: 8)
                    Image(systemName: context.attributes.image).multilineTextAlignment(.center)
                }
                DynamicIslandExpandedRegion(.center) {
                    // 这儿的url一定记住需要中文编码
                    let url = "hdSeckill://seckill?productId=\(context.attributes.productId)&name=\(context.attributes.name)".addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!
                    Link("当即购买", destination: URL(string: url)!).foregroundColor(.red).font(.system(size: 24, weight: .bold))
                }
                DynamicIslandExpandedRegion(.bottom) {
                    VStack(alignment: .center, content: {
                        Spacer(minLength: 8)
                        Text("价格\(context.attributes.price)").font(.subheadline)
                        Spacer(minLength: 8)
                        Text(Date().addingTimeInterval(context.attributes.countDown * 60), style: .timer).font(.system(size: 16, weight: .semibold)).multilineTextAlignment(.center)
                    }).foregroundColor(.green)
                }
            } compactLeading: {
                // 这儿是灵动岛未被打开左面的布局,这儿用来展现App的Icon
                Image("zyg100").resizable().frame(width: 32, height: 32)
            } compactTrailing: {
                // 这儿是灵动岛未被打开右边的布局,这儿用来产品的称号
                HStack {
                    Text(context.attributes.name).font(.subheadline)
                }
            } minimal: {
                // 这儿是灵动岛有多个使命的状况下,展现优先级高的使命,方位在右边的一个圆圈区域
                // 这用户展现产品的图标
                Image(systemName: context.attributes.image)
            }
            // 点击整个区域,经过deeplink将数据传递给主工程,做相应的事务
            .widgetURL(URL(string: "http://www.apple.com"))
            .keylineTint(Color.red)
        }
    }
}

3、在主工程做好根本布局及初始化产品数据

**(主工程)**布局部分因为demo展现,直接运用 storyboard 快速完结:

iOS灵动岛开发实践

// ViewController.swift
private func initProducts() {
        let carId = "2022101101"
        let carIsSeckill = checkIsSeckill(productId: carId)
        let bicycleId = "2022101102"
        let bicycleIsSeckill = checkIsSeckill(productId: bicycleId)
        let basketballId = "2022101103"
        let basketballIsSeckill = checkIsSeckill(productId: basketballId)
        let car = SeckillProductAttributes(productId:carId, name: "Model Y", price: "29.8万", image: "car.side.air.circulate", countDown: 60, isSeckill: carIsSeckill)
        let bicycle = SeckillProductAttributes(productId:bicycleId, name: "永久自行车", price: "1200", image: "bicycle", countDown: 120, isSeckill: bicycleIsSeckill)
        let basketball = SeckillProductAttributes(productId:basketballId, name: "斯伯丁篮球", price: "340", image: "basketball", countDown: 150, isSeckill: basketballIsSeckill)
        products.append(car)
        products.append(bicycle)
        products.append(basketball)
			  // 判断本地缓存和系统ActiviKit的使命数据来显现当前列表
        if carIsSeckill {
            seckillButton0.setTitle("已预定", for: .normal)
        }
        if bicycleIsSeckill {
            seckillButton1.setTitle("已预定", for: .normal)
        }
        if basketballIsSeckill {
            seckillButton2.setTitle("已预定", for: .normal)
        }
    }

点击事情,调用 ActivityKit 将数据传递给 灵动岛 Widget:

// ViewController.swift
@IBAction func seckillAction(_ sender: UIButton) {
        if sender.tag >= products.count {
            return
        }
        if !ActivityAuthorizationInfo().areActivitiesEnabled {
            logToTextView(log: "不支持灵动岛")
            return
        }
        let product = products[sender.tag]
        // 判断系统的activities是否还履行该产品的使命,只要是在履行中的,才进行撤销操作
        if sender.titleLabel?.text == "已预定" {
            if let activityId = getSeckillActivityId(productId: product.productId) {
                for activity in Activity<SeckillProductAttributes>.activities where activity.id == activityId {
                    logToTextView(log: "撤销预定购买\(product.name)")
                    Task {
                        await activity.end(dismissalPolicy:.immediate)
                    }
                    sender.setTitle("预定抢购", for: .normal)
                }
            }
            return
        }
        logToTextView(log: "开端预定购买\(product.name)")
        do {
            // 初始化状况,ContentState是可变的对象
            let initState = SeckillProductAttributes.ContentState(isSeckill: true)
            // 初始化状况,这儿是不变的数据
            let activity = try Activity.request(attributes: product, contentState: initState)
            logToTextView(log: "activityId: \(activity.id)")
            sender.setTitle("已预定", for: .normal)
            // 将产品id和活动id关联起来,便利查询及撤销操作
            saveSeckillState(productId: product.productId, activityId: activity.id)
        } catch {
        }
    }

同步管理活动数据及系统activities的状况:

// ViewController.swift
extension ViewController {
    // 保存产品的预定状况,key是产品id,value是activity的id
    static let seckillProductIds = "com.harry.toolbardemo.seckillProductIds"
    private func checkIsSeckill(productId: String) -> Bool {
        if let ids = UserDefaults.standard.value(forKey: ViewController.seckillProductIds) as? [String: String] {
            // 本地缓存包括该产品ID,而且系统的Activity依旧存在
            if ids.keys.contains(productId) {
                for activity in Activity<SeckillProductAttributes>.activities where activity.id == ids[productId] {
                    return true
                }
            }
        }
        return false
    }
    private func saveSeckillState(productId: String, activityId: String) {
        var ids = [String: String]()
        if let tempIds = UserDefaults.standard.value(forKey: ViewController.seckillProductIds) as? [String: String] {
            ids = tempIds
        }
        ids[productId] = activityId
        UserDefaults.standard.set(ids, forKey: ViewController.seckillProductIds)
    }
    private func getSeckillActivityId(productId: String) -> String? {
        if let ids = UserDefaults.standard.value(forKey: ViewController.seckillProductIds) as? [String: String] {
            return ids[productId]
        }
        return nil
    }
    private func removeSeckillActivityId(productId: String) {
        if var ids = UserDefaults.standard.value(forKey: ViewController.seckillProductIds) as? [String: String] {
            ids.removeValue(forKey: productId)
            UserDefaults.standard.set(ids, forKey: ViewController.seckillProductIds)
        }
    }
}

4、解析灵动岛 Widget的数据,而且做相应的操作

**(主工程中)**外部引发会履行到 SceneDelegate 或许 AppDelegate 的系统API中,这儿以 SceneDelegate 为例说明:

// SceneDelegate.swift
// 完结该方法,接纳而且处理外部引发的数据做相应的事情
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
    if let url = URLContexts.first?.url {
        debugPrint("url: \(url)")
        if url.scheme == "hdSeckill" {
            // 经过scheme来区分灵动岛相关的数据
            ActivityBrigde.activityAction(url: url)
        }
    }
}
// SeckillProduct.swift
// 解析灵动岛的传递数据,做相应的事情,这儿经过告诉给主工程的控制器履行相应使命
struct ActivityBrigde {
    @discardableResult
    public static func activityAction(url: URL) -> Bool {
        let host = url.host
        guard host != nil else { return false }
        let queryItems = URLComponents(string: url.absoluteString)?.queryItems
        guard let queryItems = queryItems else { return false }
        var productId : String?
        var name : String?
        for item in queryItems {
            // 获取产品id和称号
            if item.name == "productId" {
                productId = item.value
            }
            else if item.name == "name" {
                name = item.value
            }
        }
        guard let productId = productId else { return false }
        debugPrint("当即抢购[\(name ?? "")] \(productId)")
        let info = [
            "productId": productId,
            "name": name ?? ""
        ]
        NotificationCenter.default.post(name: Notification.Name("liveActivityNotif"), object: nil, userInfo: info)
        return true
    }
}

(主工程中) 主控制器监听该告诉,而且做相应的使命处理:

//  ViewController.swift
override func viewDidLoad() {
    super.viewDidLoad()
    NotificationCenter.default.addObserver(self, selector: #selector(liveActivityNotif(notif:)), name: Notification.Name("liveActivityNotif"), object: nil)
}
extension ViewController {
    @objc private func liveActivityNotif(notif: Notification) {
        if let userInfo = notif.userInfo {
            if let productId = userInfo["productId"] as? String, let name = userInfo["name"] as? String {
                logToTextView(log: "当即抢购[\(name)] \(productId) \n")
            }
        }
    }
    private func logToTextView(log: String) {
        debugPrint(log);
        logTextView.text.append("\(log) \n")
        logTextView.scrollRectToVisible(CGRect(x: 0, y: logTextView.contentSize.height - 10, width: logTextView.contentSize.width, height: 10), animated: true)
    }
}

五、小结

整体的开发本钱仍是很低,根本上按照后文有几篇优异的博客操作即可完结归于自己想法的灵动岛。之前没有触摸过 WidgetSwiftUI 开发的iOS同学也不必担心。相信在不同类型的使用中,我们必定也可以找到归于自己的创意给用户更高的体会感。

本文涉及到的代码地址为 HDSeckillDemo 欢迎我们 Star

六、参考链接

ActivityKit官方文档

iOS 16 Live Activity

iOS16.1灵动岛适配 (ActivityKit 初探)

实时活动(Live Activity) – 在确定屏幕和灵动岛上显现使用程序的实时数据

Dynamic Island API – iOS 16.1 Beta – Xcode 14.1 – SwiftUI Tutorials

可能是全网第一个适配iOS灵动岛的Toast库-JFPopup