Extend your app's controls across the system
System Frameworks 进阶 20m

用 Controls API 把你的 App 功能扩展到控制中心和锁屏

Extend your app's controls across the system

2024年6月10日

在 Apple 官方观看视频

一句话判断

iOS 18 的 Controls API 让你的 App 操作(开/关、启动/停止)直接出现在控制中心、锁屏和 Action Button 上——本质上是用 WidgetKit 的架构把”按钮”做成了系统级控件。

这场 Session 讲了什么

iOS 18 在 WidgetKit 框架中新增了 Control 这一控件类型。Control 有两种形态:Button(执行一次性操作,比如深链接到 App 的某个页面)和 Toggle(切换布尔状态,比如开关某个功能)。Control 可以出现在三个系统空间:Control Center(三种尺寸)、Lock Screen、以及 iPhone 15 Pro 的 Action Button。

演讲者 Cliff 用一个”生产力计时器”作为贯穿全场的示例。这个计时器 Control 在锁屏上可以启动、在控制中心可以停止、通过 Action Button 可以切换运行状态——同一套代码,三个入口。Control 底层使用 App Intent 来执行操作,和 interactive widget 的机制一致。你的 App 提供符号图标、标题、tint 颜色等视觉信息,系统负责根据放置位置决定最终的显示样式。

Session 还讲了 Control 的状态管理机制:系统在你 Control 的 App Intent 执行完毕后自动触发 reload,你的 App 也可以通过 ControlCenter API 主动请求刷新,还支持通过 push notification 触发 reload(适合跨设备同步场景)。

值得深挖的点

Control 的状态同步:本地、App 侧、跨设备

Control 的状态管理有三个层次。最简单的是本地状态——用户在 Control Center 点击 toggle,App Intent 的 perform() 方法修改共享容器中的状态,perform() 返回后系统自动 reload Control 来反映新状态。

第二个层次是 App 侧变更。用户可能直接在 App 里切换了状态(比如在生产力 App 里手动开始计时),此时 App 需要调用 ControlCenter.shared.reloadControls(ofKind:) 来通知系统刷新 Control 的显示。如果不做这一步,Control 上显示的状态和实际状态就会不一致。

第三个层次是跨设备同步。如果你的状态存储在服务端,Control 需要通过 push notification 来获取状态更新。这时你需要在 Control 中使用 TimelineEntry 来承载来自服务器的状态数据,push notification 的 payload 会触发系统重新执行 Control 的 body 来获取最新内容。Cliff 特别提到,如果你频繁刷新 Control 进行调试,可以在开发者设置中开启 WidgetKit Developer Mode 来绕过系统的刷新频率限制。

可配置 Control:从 Static 到 Configurable

Session 中先展示了 StaticControlConfiguration(不可配置的 Control),后面升级到了 IntentConfigurable。可配置 Control 允许用户在添加 Control 时选择参数——比如你的计时器 Control 可以让用户选择时长(25 分钟番茄钟还是 5 分钟休息)。配置过程复用了 App Intent 的参数机制,系统会自动生成配置 UI。

可配置 Control 的一个实用场景是:你的 App 有多个设备或多个场景,用户可以在 Control Center 里添加多个 Control 实例,每个实例配置不同的设备或场景。比如智能家居 App,用户可以添加多个灯光 Control,每个控制不同的灯。

代码片段

基础 Toggle Control

// 在已有的 WidgetBundle 中添加 Control
@main
struct ProductivityWidgetBundle: WidgetBundle {
    var body: some Widget {
        ProductivityWidget()
        TimerToggle()  // 新增的 Control
    }
}

// 定义一个 Toggle 类型的 Control
struct TimerToggle: ControlWidget {
    var body: some ControlWidgetConfiguration {
        StaticControlConfiguration(kind: "com.example.timer-toggle") {
            // 使用 ControlWidgetToggle 创建开关控件
            ControlWidgetToggle(isOn: TimerManager.shared.isRunning) { isOn in
                // 根据状态切换图标
                Label(
                    isOn ? "运行中" : "已停止",
                    systemImage: isOn ? "hourglass" : "hourglass.badge.fill"
                )
            } action: {
                // 通过 App Intent 执行操作
                ToggleTimerIntent()
            }
            .tint(.purple)  // 自定义 tint 颜色
        }
    }
}

坑点:Control 在锁屏和小尺寸模式下只显示 symbol,不显示标题和 value text。所以你选择的 SF Symbol 必须能独立传达含义。

使用 App Intent 执行 Control 操作

// Control 的操作通过 App Intent 实现
struct ToggleTimerIntent: SetValueIntent {
    static let title: LocalizedStringResource = "切换计时器"
    static let description = IntentDescription("开始或停止生产力计时器")

    @Parameter(title: "运行状态")
    var value: Bool

    // SetValueIntent 需要实现 perform 方法
    func perform() async throws -> some IntentResult {
        // 更新共享容器中的状态
        TimerManager.shared.setRunning(value)

        // 如果需要,同时管理 Live Activity
        if value {
            let activityContent = ActivityContent(
                state: TimerAttributes.LiveActivityState(remainingSeconds: 1500),
                staleDate: nil
            )
            let attributes = TimerAttributes(totalSeconds: 1500)
            try Activity.request(
                attributes: attributes,
                content: activityContent
            )
        } else {
            let activities = Activity<TimerAttributes>.activities
            for activity in activities {
                await activity.end(nil, dismissalPolicy: .immediate)
            }
        }

        return .result()
    }
}

坑点:perform() 方法返回后系统才会 reload Control,所以所有状态更新必须在 perform() 返回前完成。如果你有异步操作,用 await 等待完成后再返回。

从 App 侧主动刷新 Control

// 在 App 内部状态变化时,通知 Control 刷新
class TimerManager: ObservableObject {
    static let shared = TimerManager()

    func setRunning(_ running: Bool) {
        // 更新共享容器中的状态
        UserDefaults(suiteName: "group.com.example.productivity")?
            .set(running, forKey: "timerRunning")

        // 通知 Control 刷新显示
        ControlCenter.shared.reloadControls(
            ofKind: "com.example.timer-toggle"
        )
    }
}

坑点:reloadControls 调用有频率限制,正常使用不会触发,但如果你在循环中频繁调用可能会被系统忽略。开发阶段开启 WidgetKit Developer Mode 可以解除限制。

最佳实践

如果你有 interactive widget 的开发经验,Controls 的学习曲线很低——同样的 WidgetKit 架构,同样的 App Intent 机制。建议的迁移路径:先从一个简单的 Toggle Control 开始,让你的核心开关功能出现在控制中心和锁屏上。验证基本功能后,再考虑加入 Configurable 支持,允许用户自定义 Control 的行为参数。

一个容易忽视的点是 Control 的视觉设计。Control 在不同系统空间中的显示尺寸差异很大——Control Center 有三种尺寸,锁屏上只有一个圆形图标的位置。你的 SF Symbol 必须在小尺寸下也能清晰辨识,tint 颜色要确保在深色和浅色模式下都有足够对比度。不要过度依赖文字来传达信息,因为文字在很多显示场景下是看不到的。

还有什么值得关注

  • Control 支持 push notification 触发 reload,适合状态存储在服务端的场景(比如 IoT 设备控制)。
  • Action Button 在 iOS 18 对所有开发者开放,不再只是系统功能的专属,你可以让用户把你的 Control 绑定到 Action Button 上。
  • Control 和 Live Activity 可以联动——比如计时器 Control 启动后同时触发一个 Live Activity 来显示倒计时。
WWDC 2024