ハイライト
iOS 17 では Widget が読み取り専用の表示からインタラクティブなコントロールへ進化しました。開発者は Button と Toggle で Widget 上から直接 AppIntents を起動でき、新しいアニメーション API と組み合わせてタイムラインエントリの切り替え時に滑らかなコンテンツ遷移を実現できます。
主要内容
これまでの Widget:見るだけで触れない
WidgetKit が iOS 14 で Widget を導入したとき、その役割は単純でした。情報を表示することだけです。ユーザーが Widget をタップしても App が開くだけで、Widget 自体はジェスチャーに応答しません。Widget 上で ToDo のチェックやコーヒーの記録のような操作をさせたい場合、ユーザーを App に連れていく必要がありました。
開発者にとって、Widget は表示用の窓に過ぎず、機能の入口ではありませんでした。操作のたびに「Widget をタップ → App を開く → 操作 → 戻る」という流れが発生していました。
iOS 17 の変化:Widget がインタラクションに対応
iOS 17 では WidgetKit に 2 つの重要な能力が追加されました。
- インタラクティブコントロール:Widget に Button と Toggle を含められ、タップすると App を開かずに AppIntent を直接実行できます。
- アニメーション遷移:タイムラインエントリの切り替え時に、アニメーションとコンテンツ遷移をカスタマイズでき、いきなり切り替わる必要がなくなりました。
この 2 つを組み合わせることで、Widget は「情報掲示板」から「ミニ App の操作パネル」へと変わります。
仕組みの中心:AppIntents
Widget インタラクションの核心は AppIntents フレームワークです。Button と Toggle のイニシャライザは AppIntent オブジェクトを受け取り、ユーザーがタップするとシステムがその Intent の perform() を実行します。perform() が返ると、WidgetKit が自動的に Widget UI を更新します。
この設計により、Widget は自分で状態を管理する必要がありません。App のビジネスロジックを起動し、最新の結果を表示するだけです。
詳細
Widget 背景のカスタマイズ:containerBackground
iOS 17 では Widget 背景専用の新しい containerBackground modifier が提供されます(03:54)。
.containerBackground(for: .widget) {
Color.cosmicLatte
}
キーポイント:
containerBackground(for: .widget)は WidgetKit の新 modifier で、従来のbackgroundの使い方に代わります- システムは Widget の配置場所(ホーム画面、ロック画面、スタンバイ)に応じて背景の描画方法を調整します
- スタンバイモードでは、背景に適切な角丸と余白が追加されます
Widget アニメーションのプレビュー:#Preview マクロ
Xcode 15 の新しい #Preview マクロで、Widget のタイムラインアニメーションを直接プレビューできます(04:22)。
#Preview(as: WidgetFamily.systemSmall) {
CaffeineTrackerWidget()
} timeline: {
CaffeineLogEntry.log1
CaffeineLogEntry.log2
CaffeineLogEntry.log3
CaffeineLogEntry.log4
}
キーポイント:
#Preview(as:)で Widget サイズを指定します。systemSmall、systemMedium、systemLargeなどに対応timeline:クロージャは複数のタイムラインエントリを返し、プレビューが自動的に切り替わります- 切り替え時に定義したアニメーションと遷移効果が再生され、実機テストが不要です
数値変化の滑らかな遷移:contentTransition
Widget 内の数値変化には .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)はlogが変わったとき別のビューとして扱い、遷移アニメーションを起動します.transition(.push(from: .bottom))は旧エントリが上に押し出され、新エントリが下から入る遷移を定義します.animation(.smooth(duration: 1.8), value: log)でカーブと時間を指定し、logが変わったときだけ発火しますduration: 1.8は Widget 上で自然に見える長めのアニメーションです
Widget タイムラインの手動更新
App 内のデータが変わったら、WidgetKit に Widget の更新を通知します(09:18)。
WidgetCenter.shared.reloadTimelines(ofKind: "LocationForecast")
キーポイント:
WidgetCenter.shared.reloadTimelines(ofKind:)は指定 kind の Widget を更新します- Widget が依存するデータを App が変更した後に呼び出します
- AppIntent の
perform()内でデータを変更した後に呼ぶのに適していますが、Button/Toggle の AppIntent は自動更新されます
AppIntent によるインタラクション:Button
Widget インタラクションの核心メカニズムです。まず 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 titleとdescriptionは Siri とショートカットでの表示に使われます@Parameterのプロパティは外部から値を受け取り、optionsProviderが選択肢を提供しますperform()がビジネスロジックを実行する場所で、DrinksLogStore.shared.log(drink:)はカスタムのデータ保存メソッドですperform()が.result()を返すと、WidgetKit が自動的に Widget を再読み込みします
次に Widget ビューで 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 の新しいイニシャライザです- ボタンをタップすると App を開かずに
LogDrinkIntentのperform()が実行されます .tint()でボタン色を設定できます- 1 つの Widget に複数のボタンを置き、それぞれ別の Intent にバインドできます
体感遅延の低減:invalidatableContent
ユーザーがボタンをタップして AppIntent を起動した後、データ更新から Widget 更新までに時間差があります。.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:))と組み合わせると効果的です- ユーザーへの即時フィードバックが必要な場面に適しています
重要ポイント
-
コーヒー/水分記録 Widget を作る:Widget に飲み物ボタン(エスプレッソ、ラテ、水)を置き、タップで HealthKit やローカルストレージに記録し、総摂取量を数字スクロールアニメーションで表示。価値:本 Session のデモはカフェイン追跡器で、Button + AppIntent + contentTransition をすべて使っています。始め方:Drink 型と
LogDrinkIntentを定義し、Button(intent:)でボタンを作り、perform()でWidgetCenter.shared.reloadTimelinesを呼びます。 -
ポモドーロ Widget を作る:現在のタスクと残り時間を表示し、Toggle でタイマーの開始/一時停止、
.contentTransition(.numericText(value:))でカウントダウンアニメーション。価値:Toggle は Widget がサポートする 2 つ目のインタラクティブコントロールで、カウントダウンは数字遷移の自然なユースケースです。始め方:TimerIntentを作成し、Toggle(isOn:intent:label:)でタイマー状態をバインド、perform()でバックグラウンドタイマーを開始/一時停止します。 -
習慣チェックイン Widget を作る:今日の習慣リストを表示し、各習慣に Button を置いて完了をマーク、完了項目は
.transition(.push(from: .bottom))で下に消えます。価値:習慣追跡は Widget の高頻度ユースケースで、アニメーション遷移がチェックイン体験を滑らかにします。始め方:各習慣にMarkHabitIntentを作り、ForEach でリストし、それぞれに Button を付けます。 -
クイック支払い Widget を作る:よく使う金額ボタン(朝食 15 円、コーヒー 25 円、昼食 40 円)を置き、タップで家計 App に記録、月間合計を数字アニメーションで表示。価値:家計記録の最大の摩擦は App を開いて金額を入力することで、Widget ボタンは 1 タップに短縮します。始め方:
RecordExpenseIntentを作成し、@Parameterで金額とカテゴリを受け取り、perform()でローカル DB に書き込みます。 -
スマートホーム制御 Widget を作る:Toggle で照明のオン/オフ、Button でシーンモード切り替え、
.tint()でボタン色をデバイス状態に合わせます。価値:ホーム制御は Widget インタラクションの典型で、App を開かずにデバイスを操作できます。始め方:各デバイスにToggleDeviceIntentを作り、HomeKit や MQTT 層で実行し、.invalidatableContent()でコマンド実行中の遅延を処理します。
関連セッション
- Bring widgets to new places — iOS 17 で Widget がロック画面、スタンバイ、Mac デスクトップに拡張。各環境への適応方法
- Meet SwiftUI for spatial computing — 空間計算における SwiftUI の使い方。Widget インタラクションと同じ AppIntents 基盤を共有
- Explore enhancements to App Intents — AppIntents の最新強化。Widget インタラクションのエンジン
- Design dynamic Live Activities — Live Activities も同じ Button と Toggle インタラクションに対応。Dynamic Island でも操作可能
- What’s new in AppKit — macOS の Widget インタラクション対応。デスクトップ Widget でも Button と Toggle が使えます
コメント
GitHub Issues · utterances