ハイライト
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)
}
}
}
キーポイント:
LabelはImageの代わりに文字とシンボルを同時表示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 を変更することを示す@Parameterのvalueはシステムが現在の状態から自動入力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 を設定。
関連セッション
- Bring expression to your app with Genmoji — アプリで Genmoji を生き生きと表現する方法
- Catch up on accessibility in SwiftUI — SwiftUI で誰もが使いやすいアクセシビリティ体験を構築
- Demystify SwiftUI containers — SwiftUI コンテナビューの能力とメンタルモデルの構築
- Evolve your document launch experience — ドキュメントベースアプリを際立たせ、独自のアイデンティティを前面に
コメント
GitHub Issues · utterances