iOS靈動(dòng)島【電商秒殺】開(kāi)發(fā)實(shí)踐

一、基本概述

名詞基礎(chǔ)知識(shí)

蘋(píng)果在 iPhone 14 Pro 系列中增加一個(gè)靈動(dòng)島,主要目的是隱藏挖孔造型的高端“感嘆號(hào)屏”。 通過(guò)動(dòng)畫(huà)的視覺(jué)差異,用戶(hù)找不到原來(lái)的挖孔屏。靈動(dòng)島是一種巧妙的設(shè)計(jì),模糊了軟件和硬件之間的界限,它可以在鎖屏的情況下根據(jù)不同的應(yīng)用程序操作和提示、通知和活動(dòng)內(nèi)容的需求,自動(dòng)改變大小和形狀,展示用戶(hù)關(guān)注的事情。

ldd01.jpg
ldd02.jpg

開(kāi)發(fā)基礎(chǔ)知識(shí)

1、設(shè)備只支持iPhone,并且是有“藥丸屏”的iPhone14Pro和14Pro Max上;

2、Max系統(tǒng)版本、編譯器及iOS系統(tǒng)版本:>=MacOS12.4、>=Xcode14.0+beta4、>=iOS16.1+beta;

3、使用 ActivityKit 用于配置、開(kāi)始、更新、結(jié)束實(shí)現(xiàn) Live Activity 能力。使用 WidgetKit 、SwiftUIwidget 小組件中創(chuàng)建 Live Activity 的用戶(hù)界面,這樣小組件和 Live Activity 的代碼是可以共享;

4、Live Activity 目前只能通過(guò) ActivityKit 從主工程獲取數(shù)據(jù),或者從 遠(yuǎn)程通知 獲取最新數(shù)據(jù);無(wú)法訪(fǎng)問(wèn)網(wǎng)絡(luò)或者接受位置更新信息

5、 ActivityKit 和 遠(yuǎn)程通知推送 更新的數(shù)據(jù)不能超過(guò)4KB;

6、Live Activity 可以給不同的控制綁定不同的 deeplink,使其跳轉(zhuǎn)到不同的頁(yè)面;

7、Live Activity 在用戶(hù)主動(dòng)結(jié)束前最多存活8小時(shí);

8、已經(jīng)結(jié)束的 Live Activity 在鎖屏也最多保留4小時(shí),所以一個(gè) Live Activity 最長(zhǎng)可以停留12小時(shí);

9、最多同時(shí)存在兩組 Live Activity ,排列順序待發(fā)現(xiàn)

二、項(xiàng)目構(gòu)思

這里以電商常用的搶購(gòu)商品作為實(shí)踐,包含的交互方式主要有如下特征:

1、主工程商品能展示基本信息,包含圖片、名稱(chēng)、價(jià)格、開(kāi)搶時(shí)間、預(yù)約按鈕等;

2、主工程記錄商品預(yù)約狀態(tài)及靈動(dòng)島的任務(wù)狀態(tài),避免重復(fù)預(yù)約;

3、開(kāi)啟預(yù)約后,在打開(kāi)App的情況下,靈動(dòng)島看到該商品的基本信息,支持立即搶購(gòu);

包含了開(kāi)發(fā)靈動(dòng)島的基礎(chǔ)知識(shí):

1、主工程數(shù)據(jù)通過(guò) ActivityKit 來(lái)啟動(dòng)、更新、停止靈動(dòng)島 Widget;

2、靈動(dòng)島 Widget 的基本布局方式及開(kāi)發(fā)注意事項(xiàng);

3、靈動(dòng)島 Widget 點(diǎn)擊跳轉(zhuǎn)到主工程的事件通訊;

三、效果展示

2022-10-11-21-10ko0jrkp11N11Px11Qd.gif

四、代碼實(shí)踐

1、創(chuàng)建主工程及靈動(dòng)島Widget工程

ldd03.jpg
ldd04.jpg

在主工程的 info.plist 文件中添加 Supports Live Activities 并且設(shè)置為 YES

2、熟悉 ActivityKit 常用對(duì)象及API,創(chuàng)建基本視圖

數(shù)據(jù)部分(主工程):繼承 ActivityAttributes ,定義常用的數(shù)據(jù)來(lái)控制或改變UI及狀態(tài)。這里包含的商品的基本信息,可以認(rèn)為是不變的狀態(tài),可變的狀態(tài)需要在 ContentState 中聲明。

// SeckillProduct.swift

import Foundation
import ActivityKit

struct SeckillProductAttributes: ActivityAttributes {
    typealias SeckillProductState = ContentState
    public struct ContentState: Codable, Hashable {
          // 可變的屬性需要放在這里,activity調(diào)用update進(jìn)行數(shù)據(jù)的更新
        var isSeckill: Bool
    }
    
    // 一個(gè)靈動(dòng)島任務(wù)的初始化數(shù)據(jù),描述一些不可變數(shù)據(jù)
    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
    }
}

靈動(dòng)島布局(Widget工程):繼承 Widget ,通過(guò) ActivityConfiguration 來(lái)管理鎖屏及靈動(dòng)島的布局。

這里包含了如何從主工程獲取數(shù)據(jù)展示及將點(diǎn)擊事件傳遞給主工程的代碼示例:

// HDSeckillAvtivityLiveActivity.swift

import ActivityKit
import WidgetKit
import SwiftUI

struct HDSeckillAvtivityLiveActivity: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: SeckillProductAttributes.self) { context in
            // 鎖屏之后,顯示的桌面通知欄位置,這里可以做相對(duì)復(fù)雜的布局
            VStack {
                Text("Hello").multilineTextAlignment(.center)
            }
            .activityBackgroundTint(Color.cyan)
            .activitySystemActionForegroundColor(Color.black)
            
        } dynamicIsland: { context in
            // 靈動(dòng)島的布局代碼
            DynamicIsland {
                /*
                 這里是長(zhǎng)按靈動(dòng)島區(qū)域后展開(kāi)的UI
                 有四個(gè)區(qū)域限制了布局,分別是左、右、中間(硬件下方)、底部區(qū)域
                 這里采取左邊為App的Icon、右邊為上下布局(商品名稱(chēng)+商品圖標(biāo))、
                 中間為立即購(gòu)買(mǎi)按鈕,支持點(diǎn)擊deeplink傳參喚起App、
                 底部為價(jià)格和倒計(jì)時(shí)區(qū)域
                 */
                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("立即購(gòu)買(mǎi)", destination: URL(string: url)!).foregroundColor(.red).font(.system(size: 24, weight: .bold))
                }
                DynamicIslandExpandedRegion(.bottom) {
                    VStack(alignment: .center, content: {
                        Spacer(minLength: 8)
                        Text("價(jià)格\(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: {
                // 這里是靈動(dòng)島未被展開(kāi)左邊的布局,這里用來(lái)展示App的Icon
                Image("zyg100").resizable().frame(width: 32, height: 32)
            } compactTrailing: {
                // 這里是靈動(dòng)島未被展開(kāi)右邊的布局,這里用來(lái)商品的名稱(chēng)
                HStack {
                    Text(context.attributes.name).font(.subheadline)
                }
            } minimal: {
                // 這里是靈動(dòng)島有多個(gè)任務(wù)的情況下,展示優(yōu)先級(jí)高的任務(wù),位置在右邊的一個(gè)圓圈區(qū)域
                // 這用戶(hù)展示商品的圖標(biāo)
                Image(systemName: context.attributes.image)
            }
            // 點(diǎn)擊整個(gè)區(qū)域,通過(guò)deeplink將數(shù)據(jù)傳遞給主工程,做相應(yīng)的業(yè)務(wù)
            .widgetURL(URL(string: "http://www.apple.com"))
            .keylineTint(Color.red)
        }
    }
}

3、在主工程做好基本布局及初始化商品數(shù)據(jù)

(主工程)布局部分因?yàn)閐emo展示,直接使用 storyboard 快速完成:

ldd05.jpg
// 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萬(wàn)", image: "car.side.air.circulate", countDown: 60, isSeckill: carIsSeckill)
        let bicycle = SeckillProductAttributes(productId:bicycleId, name: "永久自行車(chē)", 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)
        
              // 判斷本地緩存和系統(tǒng)ActiviKit的任務(wù)數(shù)據(jù)來(lái)顯示當(dāng)前列表
        if carIsSeckill {
            seckillButton0.setTitle("已預(yù)約", for: .normal)
        }
        if bicycleIsSeckill {
            seckillButton1.setTitle("已預(yù)約", for: .normal)
        }
        if basketballIsSeckill {
            seckillButton2.setTitle("已預(yù)約", for: .normal)
        }
    }

點(diǎn)擊事件,調(diào)用 ActivityKit 將數(shù)據(jù)傳遞給 靈動(dòng)島 Widget:

// ViewController.swift
@IBAction func seckillAction(_ sender: UIButton) {
        if sender.tag >= products.count {
            return
        }
        
        if !ActivityAuthorizationInfo().areActivitiesEnabled {
            logToTextView(log: "不支持靈動(dòng)島")
            return
        }
        
        let product = products[sender.tag]
        // 判斷系統(tǒng)的activities是否還執(zhí)行該商品的任務(wù),只有是在執(zhí)行中的,才進(jìn)行取消操作
        if sender.titleLabel?.text == "已預(yù)約" {
            if let activityId = getSeckillActivityId(productId: product.productId) {
                for activity in Activity<SeckillProductAttributes>.activities where activity.id == activityId {
                    logToTextView(log: "取消預(yù)約購(gòu)買(mǎi)\(product.name)")
                    Task {
                        await activity.end(dismissalPolicy:.immediate)
                    }
                    sender.setTitle("預(yù)約搶購(gòu)", for: .normal)
                }
            }
            return
        }
        
        logToTextView(log: "開(kāi)始預(yù)約購(gòu)買(mǎi)\(product.name)")
        do {
            // 初始化狀態(tài),ContentState是可變的對(duì)象
            let initState = SeckillProductAttributes.ContentState(isSeckill: true)
            // 初始化狀態(tài),這里是不變的數(shù)據(jù)
            let activity = try Activity.request(attributes: product, contentState: initState)
            logToTextView(log: "activityId: \(activity.id)")
            sender.setTitle("已預(yù)約", for: .normal)
            // 將商品id和活動(dòng)id關(guān)聯(lián)起來(lái),方便查詢(xún)及取消操作
            saveSeckillState(productId: product.productId, activityId: activity.id)
        } catch {
            
        }
    }

同步管理活動(dòng)數(shù)據(jù)及系統(tǒng)activities的狀態(tài):

// ViewController.swift
extension ViewController {
    // 保留商品的預(yù)約狀態(tài),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,并且系統(tǒng)的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、解析靈動(dòng)島 Widget的數(shù)據(jù),并且做相應(yīng)的操作

(主工程中)外部喚起會(huì)執(zhí)行到 SceneDelegate 或者 AppDelegate 的系統(tǒng)API中,這里以 SceneDelegate 為例說(shuō)明:

// SceneDelegate.swift

// 實(shí)現(xiàn)該方法,接收并且處理外部喚起的數(shù)據(jù)做相應(yīng)的事件
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
    if let url = URLContexts.first?.url {
        debugPrint("url: \(url)")
        if url.scheme == "hdSeckill" {
            // 通過(guò)scheme來(lái)區(qū)分靈動(dòng)島相關(guān)的數(shù)據(jù)
            ActivityBrigde.activityAction(url: url)
        }
    }
}

// SeckillProduct.swift
// 解析靈動(dòng)島的傳遞數(shù)據(jù),做相應(yīng)的事件,這里通過(guò)通知給主工程的控制器執(zhí)行相應(yīng)任務(wù)
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和名稱(chēng)
            if item.name == "productId" {
                productId = item.value
            }
            else if item.name == "name" {
                name = item.value
            }
        }
        guard let productId = productId else { return false }
        debugPrint("立即搶購(gòu)[\(name ?? "")] \(productId)")
        
        let info = [
            "productId": productId,
            "name": name ?? ""
        ]
        NotificationCenter.default.post(name: Notification.Name("liveActivityNotif"), object: nil, userInfo: info)
        
        return true
    }
}

(主工程中) 主控制器監(jiān)聽(tīng)該通知,并且做相應(yīng)的任務(wù)處理:

//  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: "立即搶購(gòu)[\(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)
    }
}

五、小結(jié)

整體的開(kāi)發(fā)成本還是很低,基本上按照后文有幾篇優(yōu)秀的博客操作即可完成屬于自己想法的靈動(dòng)島。之前沒(méi)有接觸過(guò) WidgetSwiftUI 開(kāi)發(fā)的iOS同學(xué)也不用擔(dān)心。相信在不同類(lèi)型的應(yīng)用中,大家肯定也能夠找到屬于自己的靈感給用戶(hù)更高的體驗(yàn)感。

本文涉及到的代碼地址為 HDSeckillDemo 歡迎大家 ??Star ??

六、參考鏈接

ActivityKit官方文檔

iOS 16 Live Activity

iOS16.1靈動(dòng)島適配 (ActivityKit 初探)

實(shí)時(shí)活動(dòng)(Live Activity) - 在鎖定屏幕和靈動(dòng)島上顯示應(yīng)用程序的實(shí)時(shí)數(shù)據(jù)

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

可能是全網(wǎng)第一個(gè)適配iOS靈動(dòng)島的Toast庫(kù)-JFPopup

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀(guān)點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

友情鏈接更多精彩內(nèi)容