ハイライト
watchOS 10 では WidgetKit が Apple Watch の Smart Stack に登場しました。開発者は従来の IntentConfiguration の代わりに AppIntentConfiguration を使え、TimelineEntry の relevance スコアと RelevantIntentManager でシステムが最適なタイミングに Widget を表示します。watchOS 専用の rectangular と inline accessory の 2 サイズにも対応します。
主要内容
課題:watchOS App の情報表示が十分にタイムリーでない
watchOS 10 以前、Apple Watch でリアルタイム情報を得る手段は限られていました。Complication はデータを表示できますが、スペースが小さくインタラクションも制限されます。詳細を見るには App を開く必要があり、手首を上げて数秒という場面では不便でした。
iOS の Widget は「一目で足りる」価値を証明しましたが、watchOS には同等の仕組みがありませんでした。
Apple のアプローチ:Smart Stack + WidgetKit
watchOS 10 で Smart Stack が導入されました。Digital Crown を回すか、文字盤の下から上にスワイプすると Smart Stack が現れます。タイムラインと関連性に基づいて Widget を自動配置し、ユーザーが手動で選ぶ必要はありません。
重要な変化は、WidgetKit が watchOS をサポートしたことです。iOS とほぼ同じ API で Watch 用 Widget を構築でき、watch 専用サイズ accessoryRectangular と accessoryInline が追加されています。
さらに重要なのは、システムが「関連性」に基づいて表示する Widget を決めることです。開発者が提供するのは、TimelineEntry の relevance スコア、RelevantIntentManager に登録する将来イベント、recommendations() が提供する事前設定オプションです。
Widget 構築の全体フロー
Session では Backyard Birds App を例に、Smart Stack Widget をゼロから構築します。裏庭への鳥の訪問情報と、水・餌の残り時間を表示する Widget です。
全体は 5 ステップに分かれます。
- 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:実装必須の 4 メソッド
Widget のデータ提供は TimelineProvider が制御し、placeholder、snapshot、timeline、recommendations の 4 メソッドを実装します。
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 に使います
- 裏庭未選択時は任意の裏庭にフォールバックします
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 セットを生成します
- 各訪問イベントは 2 つの entry を生成:
startDate(鳥あり)とendDate(鳥が去った) - 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 追加時に事前設定を素早く選べます
- 各裏庭に 1 つの推奨を生成し、パラメータ手動設定を不要にします
- 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 に応じて異なるサブビューに切り替えます
- Watch で最もよく使うのは
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)
}
}
キーポイント:
- 2 状態:鳥訪問時は画像と種名、いないときは噴水アイコン
.widgetAccentable()はシステムテーマ色で着色できる要素をマーク.minimumScaleFactor(0.75)で小スペースでもテキストを縮小し切り捨てを防ぐ.containerBackground(for: .widget)で Widget 背景を設定。システムがライト/ダークに適応- 水(drop.fill)と餌(fork.knife)の SF Symbol で残り時間を表示
関連性管理:適切なタイミングで Widget を表示
Smart Stack の中核は最も関連性の高い Widget を自動選択することです。開発者は 2 つのことを行います。
ステップ 1:TimelineEntry で relevance を提供(前述)。イベントありで score 10、なしで 0。システムは全 Widget の score を比較し、高い方を優先表示します。
ステップ 2: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、関連性コンテキストの 3 要素RelevantContext.date(from:to:)で時間ウィンドウを定義し、その間 Widget の関連性が高まります- 餌と水で別々の関連 Intent を作成。補給が尽きかけるときユーザーにリマインドが必要
widgetKindは Widget 定義の kind 文字列と一致させます
ステップ 3:データ変更後にタイムラインを更新(23:00):
Task {
await updateBackyardRelevantIntents()
WidgetCenter.shared.reloadTimelines(ofKind: "BackyardVisitorsWidget")
}
キーポイント:
- App データが変わったとき(例:ユーザーが補給を追加)に呼び出します
- 関連 Intent 登録を更新してからタイムラインを更新します
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)が Smart Stack 最上部へ。入口 API:TimelineEntryRelevanceと.atEnd更新戦略。 -
運動進捗 Widget:今日の Activity Rings 達成率を表示し、25% ごとにタイムライン更新。
.containerBackground(for: .widget)で進捗に応じ背景を赤から緑へ。入口 API:HealthKit データソース +TimelineProvider定期更新。 -
会議室占有状態 Widget:社内カレンダー API で会議室の空きと次の会議開始までの時間を表示。
accessoryRectangularに会議名と時間、accessoryCircularにカウントダウンリング。入口 API:カレンダーイベント変更時にWidgetCenter.shared.reloadTimelines(ofKind:)。
関連セッション
- 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