Highlight
watchOS 10 把 WidgetKit 带到了 Apple Watch 的 Smart Stack 上,开发者可以用 AppIntentConfiguration 替代旧的 IntentConfiguration,通过 TimelineEntry 中的 relevance 评分和 RelevantIntentManager 让系统自动在最佳时机展示 Widget,同时支持 rectangular 和 inline accessory 两种 watchOS 专属尺寸。
核心内容
问题:watchOS 应用的信息展示不够及时
在 watchOS 10 之前,Apple Watch 上获取实时信息的方式有限。Complications(表盘组件)可以显示数据,但它们空间小、交互受限。用户需要打开 App 才能看到详细信息,这在抬手几秒的场景里很不方便。
iOS 上的 Widget 已经证明了”看一眼就够了”的价值——但 watchOS 一直没有对等的方案。
Apple 的方案:Smart Stack + WidgetKit
watchOS 10 引入了 Smart Stack。转动 Digital Crown 或从表盘底部上滑,Smart Stack 就会出现。它会按时间线和相关性自动排列 Widget,用户不需要手动选择。
这背后的关键变化是:WidgetKit 现在支持 watchOS。开发者可以用和 iOS 几乎一样的 API 在手表上构建 Widget,只是多了两种手表专属的尺寸——accessoryRectangular 和 accessoryInline。
更重要的是,系统会根据”相关性”自动决定展示哪个 Widget。开发者需要提供的信息包括:TimelineEntry 的 relevance score、RelevantIntentManager 注册的未来事件、以及 recommendations() 提供的预配置选项。
Widget 构建的完整流程
Session 以 Backyard Birds 应用为例,演示了从零构建一个 Smart Stack Widget 的全过程。这个 Widget 展示后院小鸟的来访信息,以及水和食物的剩余时间。
整个流程分为五个步骤:
- 定义 ConfigurationIntent(使用 AppIntentConfiguration)
- 创建 TimelineEntry(含 relevance 属性)
- 实现 TimelineProvider(placeholder / snapshot / timeline / recommendations)
- 构建 SwiftUI 视图(按 widgetFamily 切换布局)
- 设置相关性管理(RelevantIntentManager + WidgetCenter)
详细内容
配置类型:从 IntentConfiguration 到 AppIntentConfiguration
watchOS 10 的 Widget 使用新的 AppIntentConfiguration,而不是旧的 IntentConfiguration。配置 Intent 遵循 WidgetConfigurationIntent 协议,直接在代码中定义参数,不再需要独立的 Intent Definition File(04:15)。
TimelineEntry:不只是时间和数据
TimelineEntry 是 Widget 数据模型的核心。除了提供显示数据外,它还负责告诉系统”这个条目有多重要”(04:15):
struct SimpleEntry: TimelineEntry {
var date: Date
var configuration: ConfigurationAppIntent
var backyard: Backyard
var bird: Bird? {
return backyard.visitorEventForDate(date: date)?.bird
}
var waterDuration: Duration {
return Duration.seconds(abs(self.date.distance(to: self.backyard.waterRefillDate)))
}
var foodDuration: Duration {
return Duration.seconds(abs(self.date.distance(to: self.backyard.foodRefillDate)))
}
var relevance: TimelineEntryRelevance? {
if let visitor = backyard.visitorEventForDate(date: date) {
return TimelineEntryRelevance(score: 10, duration: visitor.endDate.timeIntervalSince(date))
}
return TimelineEntryRelevance(score: 0)
}
}
关键点:
TimelineEntry必须包含date属性,这是系统知道何时展示该条目的依据bird是计算属性,根据当前日期查找该时段是否有鸟类来访waterDuration和foodDuration计算距离下次补给的剩余时间relevance是关键属性——有鸟来访时 score 为 10 且 duration 覆盖来访时段;没有鸟时 score 为 0
TimelineProvider:四个必须实现的方法
Widget 的数据提供流程由 TimelineProvider 控制。它需要实现四个方法:placeholder、snapshot、timeline、recommendations。
placeholder — 占位状态(07:50):
func placeholder(in context: Context) -> SimpleEntry {
return SimpleEntry(date: Date(), configuration: ConfigurationAppIntent(), backyard: Backyard.anyBackyard(modelContext: modelContext))
}
关键点:
- placeholder 在 Widget 首次加载、数据尚未就绪时显示
- 必须同步返回,不能做网络请求或复杂计算
- 使用
anyBackyard获取任意一个后院数据作为示例
snapshot — 预览快照(08:15):
func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> SimpleEntry {
if let backyard = Backyard.backyardForID(modelContext: modelContext, backyardID: configuration.backyardID) {
if let event = backyard.visitorEvents.first {
return SimpleEntry(date: event.startDate, configuration: configuration, backyard: backyard)
} else {
return SimpleEntry(date: Date(), configuration: configuration, backyard: backyard)
}
}
let yard = Backyard.anyBackyard(modelContext: modelContext)
return SimpleEntry(date: Date(), configuration: ConfigurationAppIntent(), backyard: yard)
}
关键点:
- snapshot 用于 Widget Gallery 预览,可以异步返回
- 优先使用用户选中的后院数据
- 如果有来访事件,用第一个事件的开始日期作为 entry date
- 如果用户没有选中任何后院,fallback 到任意后院
timeline — 时间线生成(16:30):
func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline<SimpleEntry> {
var entries: [SimpleEntry] = []
if let backyard = Backyard.backyardForID(modelContext: modelContext, backyardID: configuration.backyardID) {
for event in backyard.visitorEvents {
let entry = SimpleEntry(date: event.startDate, configuration: configuration, backyard: backyard)
entries.append(entry)
let afterEntry = SimpleEntry(date: event.endDate, configuration: configuration, backyard: backyard)
entries.append(afterEntry)
}
}
return Timeline(entries: entries, policy: .atEnd)
}
关键点:
- timeline 是核心方法,生成一组按时间排列的 entry
- 每个来访事件产生两个 entry:一个在
startDate(有鸟),一个在endDate(鸟飞走了) - 两个 entry 的 relevance score 不同,系统据此决定是否展示
policy: .atEnd表示时间线播完后,系统会在最后一个 entry 的时间点再次调用此方法获取新数据
recommendations — 推荐配置(18:35):
func recommendations() -> [AppIntentRecommendation<ConfigurationAppIntent>] {
var recs = [AppIntentRecommendation<ConfigurationAppIntent>]()
for backyard in Backyard.allBackyards(modelContext: modelContext) {
let configIntent = ConfigurationAppIntent()
configIntent.backyardID = backyard.id.uuidString
let gardenRecommendation = AppIntentRecommendation(intent: configIntent, description: backyard.name)
recs.append(gardenRecommendation)
}
return recs
}
关键点:
- recommendations 让用户可以在添加 Widget 时快速选择预配置选项
- 每个后院生成一个推荐,用户不用手动选择参数
- description 会显示在 Widget 选择界面中
SwiftUI 视图:按 widgetFamily 适配布局
Widget 的视图部分根据 widgetFamily 环境变量切换不同布局(10:26):
struct BackyardBirdsWidgetEntryView: View {
@Environment(\.widgetFamily) private var family
var entry: SimpleEntry
var body: some View {
switch family {
case .accessoryRectangular:
RectangularBackyardView(entry: entry)
default:
Text(entry.date, style: .time)
}
}
}
关键点:
widgetFamily是环境变量,在 watchOS 上可能的值包括accessoryRectangular、accessoryInline、accessoryCircular、accessoryCorner- 根据 family 切换不同的子视图
- 手表上最常用的是
accessoryRectangular,它提供足够的空间展示多行信息
矩形视图的完整实现(11:23):
struct RectangularBackyardView: View {
var entry: SimpleEntry
var body: some View {
HStack {
if let bird = entry.bird {
ComposedBird(bird: bird)
.scaledToFit()
.widgetAccentable()
.frame(width: 50, height: 50)
VStack(alignment: .leading) {
Text(bird.speciesName)
.font(.headline)
.foregroundStyle(bird.colors.wing.color)
.widgetAccentable()
.minimumScaleFactor(0.75)
Text(entry.backyard.name)
.minimumScaleFactor(0.75)
HStack {
Image(systemName: "drop.fill")
Text(entry.waterDuration, format: remainingHoursFormatter)
Image(systemName: "fork.knife")
Text(entry.foodDuration, format: remainingHoursFormatter)
}
.imageScale(.small)
.minimumScaleFactor(0.75)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
} else {
Image(.fountainFill)
.foregroundStyle(entry.backyard.backgroundColor)
.imageScale(.large)
.scaledToFit()
.widgetAccentable()
.frame(width: 50, height: 50)
VStack(alignment: .leading) {
Text(entry.backyard.name)
.font(.headline)
.foregroundStyle(entry.backyard.backgroundColor)
.widgetAccentable()
.minimumScaleFactor(0.75)
HStack {
Image(systemName: "drop.fill")
Text(entry.waterDuration, format: remainingHoursFormatter)
Image(systemName: "fork.knife")
Text(entry.foodDuration, format: remainingHoursFormatter)
}
.imageScale(.small)
.minimumScaleFactor(0.75)
Text("\(entry.backyard.historicalEvents.count) visitors")
.minimumScaleFactor(0.75)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.containerBackground(entry.backyard.backgroundColor.gradient, for: .widget)
}
}
关键点:
- 视图分为两种状态:有鸟来访时显示鸟的图片和物种名;没有鸟时显示喷泉图标
.widgetAccentable()标记可以被系统主题色着色的元素.minimumScaleFactor(0.75)确保文本在小空间内缩小而不截断.containerBackground(for: .widget)设置 Widget 的背景色,系统会自动适配深色/浅色模式- 水(drop.fill)和食物(fork.knife)用 SF Symbol 图标展示剩余时间
相关性管理:让系统在正确时机展示 Widget
Smart Stack 的核心能力是自动选择最相关的 Widget。开发者需要做两件事来帮助系统判断。
第一步:在 TimelineEntry 中提供 relevance(前面已展示)。有事件时 score 为 10,无事件时为 0。系统比较所有 Widget 的 score,高分者优先展示。
第二步:用 RelevantIntentManager 注册未来事件(20:47):
func updateBackyardRelevantIntents() async {
let modelContext = ModelContext(DataGeneration.container)
var relevantIntents = [RelevantIntent]()
for backyard in Backyard.allBackyards(modelContext: modelContext) {
let configIntent = ConfigurationAppIntent()
configIntent.backyardID = backyard.id.uuidString
let relevantFoodDateContext = RelevantContext.date(from: backyard.lowSuppliesDate(for: .food), to: backyard.expectedEmptyDate(for: .food))
let relevantFoodIntent = RelevantIntent(configIntent, widgetKind: "BackyardVisitorsWidget", relevance: relevantFoodDateContext)
relevantIntents.append(relevantFoodIntent)
let relevantWaterDateContext = RelevantContext.date(from: backyard.lowSuppliesDate(for: .water), to: backyard.expectedEmptyDate(for: .water))
let relevantWaterIntent = RelevantIntent(configIntent, widgetKind: "BackyardVisitorsWidget", relevance: relevantWaterDateContext)
relevantIntents.append(relevantWaterIntent)
}
do {
try await RelevantIntentManager.shared.updateRelevantIntents(relevantIntents)
} catch { }
}
关键点:
RelevantIntent包含三个要素:配置 Intent、Widget kind、相关性上下文RelevantContext.date(from:to:)定义一个时间窗口——在这个窗口内 Widget 相关性更高- 这里为食物和水分别创建了相关意图,因为补给快用完时用户需要看到提醒
widgetKind必须和 Widget 定义中的 kind 字符串一致
第三步:在数据变更后刷新时间线(23:00):
Task {
await updateBackyardRelevantIntents()
WidgetCenter.shared.reloadTimelines(ofKind: "BackyardVisitorsWidget")
}
关键点:
- 当应用数据发生变化(如用户添加了补给),调用此代码
- 先更新相关意图注册,再刷新时间线
reloadTimelines(ofKind:)会触发 timeline provider 重新生成条目
核心启发
以下是可以立刻动手实现的创意方向:
-
做一个通勤倒计时 Widget:展示下一班公交/地铁的到站时间。用
TimelineEntry存储每个班次的到达时刻,relevancescore 在即将到站时设为高分,系统自动在出门时间展示。入口 API:TimelineProvider.timeline(for:in:)中生成班次时间线条目。 -
做一个药品提醒 Widget:根据用户的服药计划,在服药时间窗口内提高 Widget 相关性。用
RelevantIntentManager提前注册未来 24 小时的服药时间点。当手表抬手时,Smart Stack 自动显示下一次该吃什么药。入口 API:RelevantIntentManager.shared.updateRelevantIntents(_:)。 -
做一个外卖/快递追踪 Widget:用
accessoryRectangular布局展示配送状态、预计送达时间和骑手距离。当配送进入最后 10 分钟时,TimelineEntryRelevance(score: 10)让 Widget 自动排到 Smart Stack 顶部。入口 API:TimelineEntryRelevance配合Timeline的.atEnd刷新策略。 -
做一个运动进度 Widget:展示今日 Activity Rings 的完成百分比,每完成 25% 更新一次时间线。用
.containerBackground(for: .widget)让背景色随进度从红色渐变到绿色。入口 API:HealthKit数据源 +TimelineProvider定期刷新。 -
做一个会议室占用状态 Widget:接入公司日历 API,展示当前会议室是否空闲、下一场会议还有多久开始。
accessoryRectangular布局放会议标题和时间,accessoryCircular放倒计时环。入口 API:WidgetCenter.shared.reloadTimelines(ofKind:)在日历事件变更时触发刷新。
关联 Session
- Bring widgets to new places — the widget ecosystem is expanding: discover how you can use the latest widgetkit apis to make your widget look great everywhere. we’ll show you how to identify your widget’s background, adjust layout dynamically, and prepare colors for vibrant rendering so that your widget can sit seamlessly in any environment.
- Bring widgets to life — learn how to make animated and interactive widgets for your apps and games. we’ll show you how to tweak animations for entry transitions and add interactivity using swiftui button and toggle so that you can create powerful moments right from the home screen and lock screen.
- Update your app for watchOS 10 — join us as we update an apple watch app to take advantage of the latest features in watchos 10. in this code-along, we’ll show you how to use the latest swiftui apis to maximize glanceability and reorient app navigation around the digital crown.
- Explore SwiftUI animation — explore swiftui’s powerful animation capabilities and find out how these features work together to produce impressive visual effects. learn how swiftui refreshes the rendering of a view, determines what to animate, interpolates values over time, and propagates context for the current transaction.
评论
GitHub Issues · utterances