WWDC Quick Look 💓 By SwiftGGTeam
Bring widgets to life

Bring widgets to life

元の動画を見る

ハイライト

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 つの重要な能力が追加されました。

  1. インタラクティブコントロール:Widget に Button と Toggle を含められ、タップすると App を開かずに AppIntent を直接実行できます。
  2. アニメーション遷移:タイムラインエントリの切り替え時に、アニメーションとコンテンツ遷移をカスタマイズでき、いきなり切り替わる必要がなくなりました。

この 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 サイズを指定します。systemSmallsystemMediumsystemLarge などに対応
  • 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.valueMeasurement<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 titledescription は 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 を開かずに LogDrinkIntentperform() が実行されます
  • .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:)) と組み合わせると効果的です
  • ユーザーへの即時フィードバックが必要な場面に適しています

重要ポイント

  1. コーヒー/水分記録 Widget を作る:Widget に飲み物ボタン(エスプレッソ、ラテ、水)を置き、タップで HealthKit やローカルストレージに記録し、総摂取量を数字スクロールアニメーションで表示。価値:本 Session のデモはカフェイン追跡器で、Button + AppIntent + contentTransition をすべて使っています。始め方:Drink 型と LogDrinkIntent を定義し、Button(intent:) でボタンを作り、perform()WidgetCenter.shared.reloadTimelines を呼びます。

  2. ポモドーロ Widget を作る:現在のタスクと残り時間を表示し、Toggle でタイマーの開始/一時停止、.contentTransition(.numericText(value:)) でカウントダウンアニメーション。価値:Toggle は Widget がサポートする 2 つ目のインタラクティブコントロールで、カウントダウンは数字遷移の自然なユースケースです。始め方:TimerIntent を作成し、Toggle(isOn:intent:label:) でタイマー状態をバインド、perform() でバックグラウンドタイマーを開始/一時停止します。

  3. 習慣チェックイン Widget を作る:今日の習慣リストを表示し、各習慣に Button を置いて完了をマーク、完了項目は .transition(.push(from: .bottom)) で下に消えます。価値:習慣追跡は Widget の高頻度ユースケースで、アニメーション遷移がチェックイン体験を滑らかにします。始め方:各習慣に MarkHabitIntent を作り、ForEach でリストし、それぞれに Button を付けます。

  4. クイック支払い Widget を作る:よく使う金額ボタン(朝食 15 円、コーヒー 25 円、昼食 40 円)を置き、タップで家計 App に記録、月間合計を数字アニメーションで表示。価値:家計記録の最大の摩擦は App を開いて金額を入力することで、Widget ボタンは 1 タップに短縮します。始め方:RecordExpenseIntent を作成し、@Parameter で金額とカテゴリを受け取り、perform() でローカル DB に書き込みます。

  5. スマートホーム制御 Widget を作る:Toggle で照明のオン/オフ、Button でシーンモード切り替え、.tint() でボタン色をデバイス状態に合わせます。価値:ホーム制御は Widget インタラクションの典型で、App を開かずにデバイスを操作できます。始め方:各デバイスに ToggleDeviceIntent を作り、HomeKit や MQTT 層で実行し、.invalidatableContent() でコマンド実行中の遅延を処理します。


関連セッション

コメント

GitHub Issues · utterances