WWDC Quick Look 💓 By SwiftGGTeam
Bring widgets to life

Bring widgets to life

观看原视频

Highlight

iOS 17 让小组件从只读展示变成可交互控件:开发者可以通过 Button 和 Toggle 在小组件上直接触发 AppIntents,配合新的动画 API 让时间线条目切换时有平滑的内容过渡。


核心内容

以前的小组件:只能看,不能碰

WidgetKit 在 iOS 14 引入小组件时,它们的作用很单一:展示信息。用户点击小组件只会打开 App,小组件本身不响应任何手势。你想让用户在小部件上完成一个操作——比如勾选一个待办、记录一杯咖啡——必须把用户带进 App。

这对开发者来说意味着:小组件只是一个展示窗口,不是一个功能入口。用户每次操作都要经历”点小组件 -> 进 App -> 操作 -> 返回”的流程。

iOS 17 的改变:小组件支持交互了

iOS 17 为 WidgetKit 加入了两个关键能力:

  1. 交互控件:小组件可以包含 Button 和 Toggle,用户点击后直接执行 AppIntent,不需要打开 App。
  2. 动画过渡:时间线条目切换时可以自定义动画和内容过渡,不再是一闪而过。

这两个能力结合起来,小组件从一个”信息展示板”变成了一个”迷你 App 操作面板”。

这一切背后的机制:AppIntents

小组件交互的核心是 AppIntents 框架。Button 和 Toggle 的初始化方法接受一个 AppIntent 对象,用户点击时系统执行该 Intent 的 perform() 方法。perform() 返回后,WidgetKit 自动刷新小组件 UI。

这个设计意味着:小组件不需要管理自己的状态——它只是触发了 App 的业务逻辑,然后显示最新的结果。


详细内容

自定义小组件背景:containerBackground

iOS 17 提供了新的 containerBackground 修饰符,专门用于小组件的背景设置(03:54):

.containerBackground(for: .widget) {
    Color.cosmicLatte
}

关键点:

  • containerBackground(for: .widget) 是 WidgetKit 新增的修饰符,取代了旧的 background 用法
  • 系统会根据小组件所在位置(主屏幕、锁屏、待机模式)自动调整背景的渲染方式
  • 在待机模式下,系统会为背景添加合适的圆角和边距

预览小组件动画:#Preview 宏

Xcode 15 提供了新的 #Preview 宏,可以直接预览小组件的时间线动画效果(04:22):

#Preview(as: WidgetFamily.systemSmall) {
    CaffeineTrackerWidget()
} timeline: {
    CaffeineLogEntry.log1
    CaffeineLogEntry.log2
    CaffeineLogEntry.log3
    CaffeineLogEntry.log4
}

关键点:

  • #Preview(as:) 指定小组件尺寸,支持 systemSmallsystemMediumsystemLarge
  • timeline: 闭包返回多个时间线条目,预览会自动在这些条目之间切换
  • 切换时会播放你定义的动画和过渡效果,不需要在真机上测试

数值变化的平滑过渡:contentTransition

小组件中的数字变化可以使用 .contentTransition(.numericText(value:)) 实现平滑的数值滚动效果(05:41):

struct TotalCaffeineView: View {
    let totalCaffeine: Measurement<UnitMass>

    var body: some View {
        VStack(alignment: .leading) {
            Text("Total Caffeine")
                .font(.caption)

            Text(totalCaffeine.formatted())
                .font(.title)
                .minimumScaleFactor(0.8)
                .contentTransition(.numericText(value: totalCaffeine.value))
        }
        .foregroundColor(.espresso)
        .bold()
        .frame(maxWidth: .infinity, alignment: .leading)
    }
}

关键点:

  • .contentTransition(.numericText(value:)) 接受一个 Double 值,当这个值变化时,文本会以数字滚动的动画方式过渡
  • totalCaffeine.value 来自 Measurement<UnitMass> 的值属性
  • 这种过渡只对数字文本生效,比默认的淡入淡出更有信息量
  • 适合展示计数、金额、度量值等场景

条目之间的推入过渡:transition 和 animation

当一个时间线条目切换到另一个时,可以用 .transition.animation 控制过渡效果(06:21):

struct LastDrinkView: View {
    let log: CaffeineLog

    var body: some View {
        VStack(alignment: .leading) {
            Text(log.drink.name)
                .bold()
            Text("\(log.date, format: Self.dateFormatStyle) · \(caffeineAmount)")
        }
        .font(.caption)
        .id(log)
        .transition(.push(from: .bottom))
        .animation(.smooth(duration: 1.8), value: log)
    }

    var caffeineAmount: String {
        log.drink.caffeine.formatted()
    }

    static var dateFormatStyle = Date.FormatStyle(
        date: .omitted, time: .shortened)
}

关键点:

  • .id(log) 告诉 SwiftUI 当 log 对象变化时,这是不同的视图,应触发过渡动画
  • .transition(.push(from: .bottom)) 定义过渡方式:旧条目向上推出,新条目从底部推入
  • .animation(.smooth(duration: 1.8), value: log) 指定动画曲线和时长,且仅在 log 变化时触发
  • duration: 1.8 给出较长的动画时间,让过渡在小组件上看起来更自然

手动刷新小组件时间线

当 App 内的数据发生变化后,需要通知 WidgetKit 刷新小组件(09:18):

WidgetCenter.shared.reloadTimelines(ofKind: "LocationForecast")

关键点:

  • WidgetCenter.shared.reloadTimelines(ofKind:) 刷新指定种类的小组件
  • 应该在你的 App 修改了小组件依赖的数据后调用
  • 适合在 AppIntent 的 perform() 方法中修改数据后调用,但 Button/Toggle 的 AppIntent 会自动触发刷新

用 AppIntent 实现交互:Button

这是小组件交互的核心机制。首先定义一个 AppIntent(13:06):

import AppIntents

struct LogDrinkIntent: AppIntent {
    static var title: LocalizedStringResource = "Log a drink"
    static var description = IntentDescription("Log a drink and its caffeine amount.")

    @Parameter(title: "Drink", optionsProvider: DrinksOptionsProvider())
    var drink: Drink

    init() {}

    init(drink: Drink) {
        self.drink = drink
    }

    func perform() async throws -> some IntentResult {
        await DrinksLogStore.shared.log(drink: drink)
        return .result()
    }
}

关键点:

  • AppIntent 协议来自 AppIntents 框架,需要 import AppIntents
  • static var titledescription 用于 Siri 和快捷指令的展示
  • @Parameter 标记的属性能从外部传入值,optionsProvider 提供可选值列表
  • perform() 方法是实际执行业务逻辑的地方,DrinksLogStore.shared.log(drink:) 是自定义的数据存储方法
  • perform() 返回 .result() 后,WidgetKit 自动重新加载小组件

然后在小组件视图中使用 Button 绑定这个 Intent(15:10):

struct LogDrinkView: View {
    var body: some View {
        Button(intent: LogDrinkIntent(drink: .espresso)) {
            Label("Espresso", systemImage: "plus")
                .font(.caption)
        }
        .tint(.espresso)
    }
}

关键点:

  • Button(intent:label:) 是 iOS 17 新增的初始化方法
  • 点击按钮时执行 LogDrinkIntentperform() 方法,不需要打开 App
  • 可以用 .tint() 设置按钮颜色
  • 同一个小组件可以有多个按钮,每个绑定不同的 Intent

减少感知延迟:invalidatableContent

当用户点击按钮触发 AppIntent 后,数据更新到小组件刷新之间有时间差。.invalidatableContent() 可以让小组件先标记内容即将变化(16:28):

struct TotalCaffeineView: View {
    let totalCaffeine: Measurement<UnitMass>

    var body: some View {
        VStack(alignment: .leading) {
            Text("Total Caffeine")
                .font(.caption)

            Text(totalCaffeine.formatted())
                .font(.title)
                .minimumScaleFactor(0.8)
                .contentTransition(.numericText(value: totalCaffeine.value))
                .invalidatableContent()
        }
        .foregroundColor(.espresso)
        .bold()
        .frame(maxWidth: .infinity, alignment: .leading)
    }
}

关键点:

  • .invalidatableContent() 标记该视图内容会在新数据到达前变化
  • 当 AppIntent 执行中时,被标记的视图会显示过渡状态,而不是卡住不动
  • 配合 .contentTransition(.numericText(value:)) 使用效果更好
  • 适合需要给用户即时反馈的场景

核心启发

  1. 做一个咖啡/饮水记录小组件:在小组件上放几个饮料按钮(浓缩咖啡、拿铁、水),点击后记录到 HealthKit 或本地存储,总摄入量用数字滚动动画展示。为什么值得做:这个 session 的演示项目就是咖啡因追踪器,Button + AppIntent + contentTransition 三个能力全用上了。怎么开始:定义 Drink 类型和 LogDrinkIntent,用 Button(intent:) 在小组件上创建几个饮料按钮,perform() 中调用 WidgetCenter.shared.reloadTimelines

  2. 做一个番茄钟小组件:小组件显示当前任务和剩余时间,用 Toggle 开始/暂停计时,时间用 .contentTransition(.numericText(value:)) 做倒数动画。为什么值得做:Toggle 是小组件支持的第二个交互控件,倒计时是最自然的数字过渡场景。怎么开始:创建一个 TimerIntent,用 Toggle(isOn:intent:label:) 绑定计时状态,在 perform() 中启动/暂停后台计时器。

  3. 做一个习惯打卡小组件:显示今日待完成习惯列表,每个习惯旁边放一个 Button,点击后标记完成,完成条目用 .transition(.push(from: .bottom)) 向下移出。为什么值得做:习惯追踪是小组件的高频使用场景,动画过渡让打卡体验更流畅。怎么开始:为每个习惯创建 MarkHabitIntent,用 ForEach 在小组件中列出习惯,每个配一个 Button。

  4. 做一个快捷支付小组件:在小组件上放几个常用金额按钮(早餐 15 元、咖啡 25 元、午餐 40 元),点击后记录到记账 App,月总支出用数字动画展示。为什么值得做:记账最大的摩擦是打开 App 输入金额,小组件按钮把操作简化到一次点击。怎么开始:创建 RecordExpenseIntent@Parameter 接收金额和分类,perform() 中写入本地数据库。

  5. 做一个智能家居控制小组件:用 Toggle 控制灯光开关,用 Button 切换场景模式,按钮颜色用 .tint() 匹配设备状态。为什么值得做:智能家居控制是小组件交互的典型场景——用户不需要打开 App 就能开关设备。怎么开始:为每个设备创建 ToggleDeviceIntent,在 HomeKit 或 MQTT 层执行操作,用 .invalidatableContent() 处理命令执行中的延迟。


关联 Session

评论

GitHub Issues · utterances