WWDC Quick Look 💓 By SwiftGGTeam
Extend your app's controls across the system

Extend your app's controls across the system

元の動画を見る

ハイライト

iOS 18 では WidgetKit フレームワークに Control ウィジェットタイプが追加され、Button と Toggle の 2 形態をサポート。コントロールセンター、ロック画面、iPhone 15 Pro の Action ボタンの 3 つのシステム空間に表示できます。

主要内容

iOS 14 で導入された WidgetKit は、天気やカレンダーイベントなどの豊富な情報をアプリが表示できるようにしました。iOS 18 ではこのフレームワークが拡張され、Control ウィジェットタイプが追加されました。Control はフラッシュライトのオン/オフや時計アプリへのディープリンクなど、クイック操作の入口に特化しています。

Control には 2 種類あります。Button は 1 回限りの操作(アプリ起動を含む)、Toggle はブール状態の切り替え(機能のオン/オフなど)です。インタラクティブ widget と同様、Control も App Intent で操作を実行します。アプリはシンボルアイコン、タイトル、tint 色などの視覚情報を提供し、システムが配置場所に応じて最終表示スタイルを決定します。

プレゼンター Cliff は「生産性タイマー」を例に Control の開発フローを示します——ロック画面で起動、コントロールセンターで停止、Action ボタンで実行状態を切り替え——同じコード、3 つの入口。Control の状態管理:App Intent 実行完了後にシステムが自動 reload をトリガー。アプリは ControlCenter API で能動的に更新を要求でき、push notification 経由の reload でクロスデバイス同期も可能です。

詳細

基本 Control の構築

Control 構築の第一歩は、既存の Widget Bundle に追加すること(03:13):

@main
struct ProductivityExtensionBundle: WidgetBundle {
    
    var body: some Widget {
        ChecklistWidget()
        TaskCounterWidget()
        TimerToggle()
    }
    
}

キーポイント:

  • TimerToggle() は新しい Control エントリ。既存 Widget と同じ Widget Bundle を共有
  • WidgetKit のアーキテクチャにより Control と Widget は同一拡張に共存可能

次に Control 本体を定義(03:29):

struct TimerToggle: ControlWidget {
    var body: some ControlWidgetConfiguration {
        StaticControlConfiguration(
            kind: "com.apple.Productivity.TimerToggle"
        ) {
            ControlWidgetToggle(
                "Work Timer",
                isOn: TimerManager.shared.isRunning,
                action: ToggleTimerIntent()
            ) { _ in
                Image(systemName:
                      "hourglass.bottomhalf.filled")
            }
        }
    }
}

キーポイント:

  • ControlWidget は Control が準拠するプロトコル
  • StaticControlConfiguration は設定不可の Control 用
  • kind パラメータは Control の一意識別子(逆 DNS 形式)
  • ControlWidgetToggle は Toggle タイプのコントロールを定義
  • isOn は TimerManager の実行状態にバインド
  • ToggleTimerIntent() はタップ時に実行される App Intent
  • クロージャが返す Image が Control のシンボルを定義

状態に応じたビジュアル設計

Control は状態に応じて異なるシンボルを表示できます(04:41):

struct TimerToggle: ControlWidget {
    var body: some ControlWidgetConfiguration {
        StaticControlConfiguration(
            kind: "com.apple.Productivity.TimerToggle"
        ) {
            ControlWidgetToggle(
                "Work Timer",
                isOn: TimerManager.shared.isRunning,
                action: ToggleTimerIntent()
            ) { isOn in
                Image(systemName: isOn
                      ? "hourglass"
                      : "hourglass.bottomhalf.filled")
            }
        }
    }
}

キーポイント:

  • クロージャ引数 isOn で状態に応じた異なるビジュアルを返せる
  • 実行中は流れる砂時計、停止中は半分埋まった砂時計
  • 状態変化時にシステムが自動的にクロージャ戻り値を再計算

value text と tint 色をさらにカスタマイズ(05:21):

struct TimerToggle: ControlWidget {
    var body: some ControlWidgetConfiguration {
        StaticControlConfiguration(
            kind: "com.apple.Productivity.TimerToggle"
        ) {
            ControlWidgetToggle(
                "Work Timer",
                isOn: TimerManager.shared.isRunning,
                action: ToggleTimerIntent()
            ) { isOn in
                Label(isOn ? "Running" : "Stopped",
                      systemImage: isOn
                      ? "hourglass"
                      : "hourglass.bottomhalf.filled")
            }
            .tint(.purple)
        }
    }
}

キーポイント:

  • LabelImage の代わりに文字とシンボルを同時表示
  • isOn ? "Running" : "Stopped" でデフォルトの “on/off” の代わりに状態テキストをカスタマイズ
  • .tint(.purple) でオン状態の色を設定(デフォルト systemBlue の代わり)
  • value text はロック画面と小サイズコントロールセンターでは非表示。シンボルのみ

App Intent の実装

Control の操作は App Intent で実行(08:14):

struct ToggleTimerIntent: SetValueIntent, LiveActivityIntent {
    static let title: LocalizedStringResource = "Productivity Timer"
    
    @Parameter(title: "Running")
    var value: Bool  // The timer's running state
    
    func perform() throws -> some IntentResult {
        TimerManager.shared.setTimerRunning(value)
        return .result()
    }
}

キーポイント:

  • SetValueIntent プロトコルは値を設定する Intent を示す
  • LiveActivityIntent プロトコルは Live Activity を変更することを示す
  • @Parametervalue はシステムが現在の状態から自動入力
  • perform() は返る前にすべての状態更新を完了する必要がある
  • システムは perform() 返却後に自動 reload

Control の能動的更新

アプリ内部で状態が変化したとき、Control の更新を能動的に要求(08:54):

func timerManager(_ manager: TimerManager,
                  timerDidChange timer: ProductivityTimer) {
    ControlCenter.shared.reloadControls(
        ofKind: "com.apple.Productivity.TimerToggle"
    )
}

キーポイント:

  • ControlCenter.shared.reloadControls(ofKind:) で指定 Control の更新を要求
  • アプリ内状態変化を Control に同期する必要があるシーン向け
  • 開発時は WidgetKit Developer Mode でシステム更新ポリシーを無効化可能

非同期状態取得

状態をサーバーやデータベースから非同期取得する場合、ControlValueProvider を使用(10:03):

struct TimerValueProvider: ControlValueProvider {
    
    func currentValue() async throws -> Bool {
        try await TimerManager.shared.fetchRunningState()
    }
    
    let previewValue: Bool = false
}

キーポイント:

  • currentValue() は非同期関数。データベースやサーバーから状態を取得
  • エラーを throw して一時的に状態を計算できないことを示せる
  • previewValue はコントロールプレビュー(ギャラリー、ロック画面カスタマイズ)用。素早く返し、通常はオフ状態

ValueProvider を使う Control の初期化(11:00):

struct TimerToggle: ControlWidget {
    var body: some ControlWidgetConfiguration {
        StaticControlConfiguration(
            kind: "com.apple.Productivity.TimerToggle",
            provider: TimerValueProvider()
        ) { isRunning in
            ControlWidgetToggle(
                "Work Timer",
                isOn: isRunning,
                action: ToggleTimerIntent()
            ) { isOn in
                Label(isOn ? "Running" : "Stopped",
                      systemImage: isOn
                      ? "hourglass"
                      : "hourglass.bottomhalf.filled")
            }
            .tint(.purple)
        }
    }
}

キーポイント:

  • provider パラメータに ValueProvider インスタンスを渡す
  • 非同期取得値はクロージャ引数 isRunning 経由で渡される
  • システムは先に ValueProvider を実行し、戻り値で Control コンテンツを生成

設定可能な Control

ユーザー設定をサポートする Control には AppIntentControlValueProvider を使用(13:06):

struct ConfigurableTimerValueProvider: AppIntentControlValueProvider {
    func currentValue(configuration: SelectTimerIntent) async throws -> TimerState {
        let timer = configuration.timer
        let isRunning = try await TimerManager.shared.fetchTimerRunning(timer: timer)
        return TimerState(timer: timer, isRunning: isRunning)
    }
    
    func previewValue(configuration: SelectTimerIntent) -> TimerState {
        return TimerState(timer: configuration.timer, isRunning: false)
    }
}

キーポイント:

  • AppIntentControlValueProvider で値が Intent 設定に依存
  • SelectTimerIntent でユーザーが制御するタイマーを選択
  • 返す TimerState はタイマーと実行状態を含むカスタム構造体

対応する Control 設定(13:40):

struct TimerToggle: ControlWidget {
    var body: some ControlWidgetConfiguration {
        AppIntentControlConfiguration(
            kind: "com.apple.Productivity.TimerToggle",
            provider: ConfigurableTimerValueProvider()
        ) { timerState in
            ControlWidgetToggle(
                timerState.timer.name,
                isOn: timerState.isRunning,
                action: ToggleTimerIntent(timer: timerState.timer)
            ) { isOn in
                Label(isOn ? "Running" : "Stopped",
                      systemImage: isOn
                      ? "hourglass"
                      : "hourglass.bottomhalf.filled")
            }
            .tint(.purple)
        }
    }
}

キーポイント:

  • AppIntentControlConfiguration は設定可能な Control 用
  • timerState はより豊富な状態情報を含むカスタム構造体
  • Control タイトルと操作対象が設定に応じて動的に変化

ユーザー設定を強制(14:26):

struct SomeControl: ControlWidget {
    var body: some ControlWidgetConfiguration {
        AppIntentControlConfiguration(
            // ...
        )
        .promptsForUserConfiguration()
    }
}

キーポイント:

  • promptsForUserConfiguration() で Control 追加時に自動的に設定 UI を表示
  • 設定がないと正常動作しない Control 向け

UI の微調整

Action Button の操作ヒントをカスタマイズ(15:42):

struct TimerToggle: ControlWidget {
    var body: some ControlWidgetConfiguration {
        AppIntentControlConfiguration(
            kind: "com.apple.Productivity.TimerToggle",
            provider: ConfigurableTimerValueProvider()
        ) { timerState in
            ControlWidgetToggle(
                timerState.timer.name,
                isOn: timerState.isRunning,
                action: ToggleTimerIntent(timer: timerState.timer)
            ) { isOn in
                Label(isOn ? "Running" : "Stopped",
                      systemImage: isOn
                      ? "hourglass"
                      : "hourglass.bottomhalf.filled")
                .controlWidgetActionHint(isOn ?
                                         "Start" : "Stop")
            }
            .tint(.purple)
        }
    }
}

キーポイント:

  • controlWidgetActionHint で Action Button のヒントテキストをカスタマイズ
  • ヒントは動詞で始める。システムが “Hold to” プレフィックスを自動追加
  • isOn ? "Start" : "Stop" で “Hold to Start” と “Hold to Stop” になる

表示名と説明を設定(16:56):

struct TimerToggle: ControlWidget {
    var body: some ControlWidgetConfiguration {
        AppIntentControlConfiguration(
            kind: "com.apple.Productivity.TimerToggle",
            provider: ConfigurableTimerValueProvider()
        ) { timerState in
            ControlWidgetToggle(
                timerState.timer.name,
                isOn: timerState.isRunning,
                action: ToggleTimerIntent(timer: timerState.timer)
            ) { isOn in
                Label(isOn ? "Running" : "Stopped",
                      systemImage: isOn
                      ? "hourglass"
                      : "hourglass.bottomhalf.filled")
                .controlWidgetActionHint(isOn ?
                                         "Start" : "Stop")
            }
            .tint(.purple)
        }
        .displayName("Productivity Timer")
        .description("Start and stop a productivity timer.")
    }
}

キーポイント:

  • displayName で Control の表示名を設定(デフォルトのアプリ名の代わり)
  • description は設定 UI に表示され、Control の機能理解を助ける
  • 各 Control には具体的な displayName を設定すべき

重要ポイント

  • 高頻度操作に Control 入口を追加: ユーザーが最もよく実行する 1〜2 操作(集中開始、ホワイトリスト切り替え、クイック記録など)を特定し、Toggle または Button Control を作成。アプリ全体の機能を詰め込まない——Control は単一機能のクイック入口であるべき。

  • 設定不可の Toggle から始める: まず StaticControlConfiguration で最もシンプルな Toggle Control を構築し、核心機能を検証。基本体験が動いてから AppIntentControlConfiguration で複数インスタンスのユーザー設定を検討。一度に複雑さを入れすぎない。

  • 小サイズ向けのシンボルを設計: Control はロック画面では円形アイコンのみ、小サイズコントロールセンターでもシンボルスペースのみ。SF Symbol が極小サイズでも識別可能であることを確認。文字に頼らない。tint 色はライト/ダークモード両方で十分なコントラストを確保。

  • 非同期状態には ValueProvider を活用: 状態をネットワークやデータベースから取得する場合、body で同期取得せず ControlValueProvider を使用。previewValue は固定で素早く返す値——通常は Control のオフ状態。

  • 能動的更新で状態同期を維持: ユーザーがアプリ内部で状態を変更したら ControlCenter.shared.reloadControls(ofKind:) で Control を能動的に更新。クロスデバイスシーンでは push notification で状態変更プッシュ受信時に自動 reload を設定。

関連セッション

コメント

GitHub Issues · utterances