iOS灵动岛

背景

2022.9.8苹果发布会上,最引人注目的一个功能灵动岛问世,当然整个发布会也只有这一个功能能拿出来提一嘴。对于用户而言灵动岛是一种新的交互式,刘海屏改成了药片屏。对于开发者而言,我们需要研究一下能为我们的APP做些什么。

灵动岛是什么

  • 灵动岛是iphone14Pro的专属特性,是iphone14pro和4 pro max两个产品的交互式。

  • 在这两个系列中,把刘海屏改为药片屏幕,给了传统的打孔屏幕一个新的观念。

  • 提到灵动岛,就必须知道实时活动Live Activity,实时活动分为两部分,一部分是锁屏界面的展示,一部分是灵动岛展示。如果开发灵动岛就需要同时兼顾锁屏的展示。在锁定屏幕上,Live Activity显示在屏幕底部。(因为灵动岛属于LiveActivity的一部分,后边我们统一使用Live Activity)

灵动岛与Widget的关系

开发Live Activity的代码都在Widget的Target里,使用SwiftUIWidgetKit来创建Live Activity的用户界面。所以Live Activity的开发跟开发Widget差不多。

与Widget不同的是,Live Activity使用不同的机制来接受更新。Live Activity不使用时间线机制,使用ActivityKit或者通过远程推送通知更新。

Live Activity的要求和限制

  • 除非应用程序或者用户结束,否则Live Activity最多可以保活8小时,超过时间,系统会自动结束。系统会将其从灵动岛中移除。但是Live Activity会一直保留在锁屏上,知道用户删除或者系统4小时后删除,所以Live Activity在锁屏上最多能存活12小时。

  • 每个Live Activity在自己应用的沙盒中运行,无法访问网络和位置更新,要更新数据,需要使用ActivityKit或者使用远程推送。动态数据的大小不能超过4Kb

  • Live Activity针对锁屏和灵动岛提供不同的视图。锁屏会出现所有设备上。支持灵动岛的设备具有四种视图:紧凑前视图、紧凑尾视图、最小视图和扩展视图。

  • 为确保系统可以在每个位置显示您的 Live Activity,开发时候必须支持所有视图。

  • 一个应用可以启动多个 Live Activity,而一个设备可以从多个应用运行 Live Activity,启动可能会失败,LiveActivity有上限个数

Live Activity的开发

Live Activity的界面开发是Widget的一部分,如果应用已经支持了Widget,就可以将代码写到Widget里。

  1. 创建Widget Target

  2. 打开Info.plist文件,添加 Supports Live Activity ,设置为YES

  3. 创建ActivityAttributes 结构,描述Live Activity的静态和动态数据。

  4. 创建ActivityConfiguration通过ActivityAttributes,启动Live Activity

  5. 添加代码以配置、启动、更新和结束。

创建Live Activity

import SwiftUI
import WidgetKit

@main
struct PizzaDeliveryActivityWidget: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: PizzaDeliveryAttributes.self) { context in
            // Create the view that appears on the Lock Screen and as a
            // banner on the Home Screen of devices that don't support the
            // Dynamic Island.
            // ...
        } dynamicIsland: { context in
            // Create the views that appear in the Dynamic Island.
            // ...
        }
    }
}

LiveActivity是一个Widget,所以代码是写在Widget的target里边。可以看到灵动岛的主函数就是继承与Wdiget。

上边函数的ActivityConfiguration第一个block就是锁屏界面的view。第二个block为灵动岛view。

创建锁屏界面的视图

系统要求高度不能超过160,超过的话会截掉

@main
struct PizzaDeliveryWidget: Widget {    
    var body: some WidgetConfiguration { 
        ActivityConfiguration(for: PizzaDeliveryAttributes.self) { context in
        // Create the view that appears on the Lock Screen and as a
            // banner on the Home Screen of devices that don't support the
            // Dynamic Island.
            LockScreenLiveActivityView(context: context)
        } dynamicIsland: { context in
            // Create the views that appear in the Dynamic Island.
            // ...
        }
    }
}

struct LockScreenLiveActivityView: View {
    let context: ActivityViewContext<PizzaDeliveryAttributes>

    var body: some View {
        VStack {
            Spacer()
            Text("\(context.state.driverName) is on their way with your pizza!")
            Spacer()
            HStack {
                Spacer()
                Label {
                    Text("\(context.attributes.numberOfPizzas) Pizzas")
                } icon: {
                    Image(systemName: "bag")
                        .foregroundColor(.indigo)
                }
                .font(.title2)
                Spacer()
                Label {
                    Text(timerInterval: context.state.deliveryTimer, countsDown: true)
                        .multilineTextAlignment(.center)
                        .frame(width: 50)
                        .monospacedDigit()
                } icon: {
                    Image(systemName: "timer")
                        .foregroundColor(.indigo)
                }
                .font(.title2)
                Spacer()
            }
            Spacer()
        }
        .activitySystemActionForegroundColor(.indigo)
        .activityBackgroundTint(.cyan)
    }
}

创建灵动岛视图

灵动岛视图分为两大部分

  • 一部分是灵动岛默认状态,就是紧凑状态

  • 一部分是长按灵动岛的扩展状态

创建紧凑和最小的视图

默认情况下,灵动岛中的紧凑和最小视图使用黑色背景颜色和白色文本。可以使用修改keylineTint(_:)

import SwiftUI
import WidgetKit

@main
struct PizzaDeliveryWidget: Widget {    
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: PizzaDeliveryAttributes.self) { context in
            // Create the view that appears on the Lock Screen and as a
            // banner on the Home Screen of devices that don't support the
            // Dynamic Island.
            // ...
        } dynamicIsland: { context in
            // Create the views that appear in the Dynamic Island.
            DynamicIsland {
                    // Create the expanded view.
                    // ...

            } compactLeading: {
                Label {
                    Text("\(context.attributes.numberOfPizzas) Pizzas")
                } icon: {
                    Image(systemName: "bag")
                        .foregroundColor(.indigo)
                }
                .font(.caption2)
            } compactTrailing: {
                Text(timerInterval: context.state.deliveryTimer, countsDown: true)
                    .multilineTextAlignment(.center)
                    .frame(width: 40)
                    .font(.caption2)
            } minimal: {
                VStack(alignment: .center) {
                    Image(systemName: "timer")
                    Text(timerInterval: context.state.deliveryTimer, countsDown: true)
                        .multilineTextAlignment(.center)
                        .monospacedDigit()
                        .font(.caption2)
                }
            }
            .keylineTint(.cyan)
        }
    }
}
创建扩展视图

视图最高160,多余截断


扩展视图分为4部分

center将内容放置在原深感摄像头下方。

leading将内容沿展开的 Live Activity 的前沿放置在原深感摄像头旁边,并在其下方包裹其他内容。

trailing将内容放置在 TrueDepth 摄像头旁边展开的 Live Activity 的后沿,并在其下方包裹其他内容。

bottom将内容置于前导、尾随和居中内容之下。


struct PizzaDeliveryWidget: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: PizzaDeliveryAttributes.self) { context in
            // Create the view that appears on the Lock Screen and as a
            // banner on the Home Screen of devices that don't support the
            // Dynamic Island.
            LockScreenLiveActivityView(context: context)
        } dynamicIsland: { context in
            // Create the views that appear in the Dynamic Island.
            DynamicIsland {
                // Create the expanded view.
                DynamicIslandExpandedRegion(.leading) {
                    Label("\(context.attributes.numberOfPizzas) Pizzas", systemImage: "bag")
                        .foregroundColor(.indigo)
                        .font(.title2)
                }

                DynamicIslandExpandedRegion(.trailing) {
                    Label {
                        Text(timerInterval: context.state.deliveryTimer, countsDown: true)
                            .multilineTextAlignment(.trailing)
                            .frame(width: 50)
                            .monospacedDigit()
                    } icon: {
                        Image(systemName: "timer")
                            .foregroundColor(.indigo)
                    }
                    .font(.title2)
                }

                DynamicIslandExpandedRegion(.center) {
                    Text("\(context.state.driverName) is on their way!")
                        .lineLimit(1)
                        .font(.caption)
                }

                DynamicIslandExpandedRegion(.bottom) {
                    Button {
                        // Deep link into your app.
                    } label: {
                        Label("Call driver", systemImage: "phone")
                    }
                    .foregroundColor(.indigo)
                }
            } compactLeading: {
                // Create the compact leading view.
                // ...
            } compactTrailing: {
                // Create the compact trailing view.
                // ...
            } minimal: {
                // Create the minimal view.
                // ...
            }
            .keylineTint(.yellow)
        }
    }
}
使用自定义颜色

系统默认设置的颜色最适合用户。如果要设置自定义颜色,可以使用activityBackgroundTint(_:)activitySystemActionForegroundColor(_:)

设置半透明,使用opacity(_:)

点击处理

跟Widget相同。

锁屏、紧凑视图使用widgetURL(_:)跳转,全局相应。

扩展视图,使用Link,可以单独按钮相应。

启动Live Activity
var future = Calendar.current.date(byAdding: .minute, value: (Int(minutes) ?? 0), to: Date())!
future = Calendar.current.date(byAdding: .second, value: (Int(seconds) ?? 0), to: future)!
let date = Date.now...future
let initialContentState = PizzaDeliveryAttributes.ContentState(driverName: "Bill James", deliveryTimer:date)
let activityAttributes = PizzaDeliveryAttributes(numberOfPizzas: 3, totalAmount: "$42.00", orderNumber: "12345")

do {
    deliveryActivity = try Activity.request(attributes: activityAttributes, contentState: initialContentState)
    print("Requested a pizza delivery Live Activity \(String(describing: deliveryActivity?.id)).")
} catch (let error) {
    print("Error requesting pizza delivery Live Activity \(error.localizedDescription).")
}

只能通过应用程序在前台时候启动,可以在后台运行时更新或者结束,比如使用Background Tasks

使用远程推送更新和关闭LiveActivity Updating and ending your Live Activity with remote push notifications.

更新Live Activity

应用程序处于前台或后台时,使用此API更新实时活动。还可以使用该功能在 iPhone 和 Apple Watch 上显示警报,告知人们新的 Live Activity 内容

更新数据的大小不能超过 4KB。

var future = Calendar.current.date(byAdding: .minute, value: (Int(minutes) ?? 0), to: Date())!
future = Calendar.current.date(byAdding: .second, value: (Int(seconds) ?? 0), to: future)!
let date = Date.now...future
let updatedDeliveryStatus = PizzaDeliveryAttributes.PizzaDeliveryStatus(driverName: "Anne Johnson", deliveryTimer: date)
let alertConfiguration = AlertConfiguration(title: "Delivery Update", body: "Your pizza order will arrive in 25 minutes.", sound: .default)

await deliveryActivity?.update(using: updatedDeliveryStatus, alertConfiguration: alertConfiguration)

在 Apple Watch 上,系统将title和body属性用于警报。在 iPhone 上,系统不会显示常规警报,而是显示动态岛中展开的实时活动。在不支持动态岛的设备上,系统会在主屏幕上显示一个横幅,该横幅使用您的实时活动的扩展视图。

结束Live Activity

已结束的实时活动将保留在锁定屏幕上,直到用户将其删除或系统自动将其删除。自动删除的控制取决于函数的dismissalPolicy参数。

另外,及时结束了也要包含更新,保证在Live Activity结束后显示最新和最终的结果。

let finalDeliveryStatus = PizzaDeliveryAttributes.PizzaDeliveryStatus(driverName: "Anne Johnson", deliveryTimer: Date.now...Date())

Task {           
    await deliveryActivity?.end(using:finalDeliveryStatus, dismissalPolicy: .default)
}

实例显示的是default策略,结束后会保留在锁定屏幕上一段时间。用户可以选择移除,或者4个小时候系统自动移除。

要立即从锁屏上移除的话,需要使用immedia属性,或者after指定时间。时间需要指定在4小时之内,超过4小时,系统会主动删除。

移除live activity,只会影响活动的展示,并不会对业务上产生影响。

通过推送来更新和结束Live Activity
{
    "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" 
        }
    }
}

跟踪更新

您启动 Live Activity 时,ActivityKit 会返回一个Activity对象。除了id唯一标识每个活动之外,Activity还提供观察内容状态、活动状态和推送令牌更新的序列。使用相应的序列在您的应用中接收更新,使您的应用和 Live Activity 保持同步,并响应更改的数据:

  • 要观察正在进行的 Live Activity 的状态——例如,确定它是处于活动状态还是已经结束——使用.activityStateUpdates

  • 要观察 Live Activity 动态内容的变化,请使用.contentState

  • 要观察 Live Activity 的推送令牌的变化,请使用.pushTokenUpdates

获取活动列表

您的应用可以启动多个 Live Activity。例如,体育应用程序可能允许用户为他们感兴趣的每个现场体育比赛启动现场活动。如果启动多个现场活动,请使用该功能获取有关您的应用程序正在进行的现场活动的通知。跟踪正在进行的 Live Activity 以确保您的应用程序的数据与 ActivityKit 跟踪的活动 Live Activity 同步。activityUpdates

以下代码段显示了披萨外卖应用程序如何检索正在进行的活动列表:

// Fetch all ongoing pizza delivery Live Activities.
for await activity in Activity<PizzaDeliveryAttributes>.activityUpdates {
    print("Pizza delivery details: \(activity.attributes)")
}

获取所有活动的另一个用例是维护正在进行的实时活动,并确保您不会让任何活动持续运行超过需要的时间。例如,系统可能会停止您的应用程序,或者您的应用程序可能会在 Live Activity 处于活动状态时崩溃。当应用下次启动时,检查是否有任何活动仍处于活动状态,更新应用存储的 Live Activity 数据,并结束任何不再相关的 Live Activity。

参考文档:

官方文档

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

推荐阅读更多精彩内容