WidgetKitの新機能、Controlを最短で実装する
概要
iOS 18でWidgetKitにControlが新機能として追加されました。
このControlに関しては従来のWidget同様、Widget Extensionで追加するだけでデフォルトでコードが生成され、コントロールセンターやロック画面などのシステムスペースに表示させることができます。ただ初期状態ではControlをタップしても状態が維持されず、何も機能しない状態です。
そこでこの記事では上記まで追加行程と、その後どのように実装すれば既存のアプリに組み込めるか、ヒントになるよう、まずは軽量な実装として状態を維持して、Controlに反映させるといった実装を最短で試す方法を紹介したいと思います。
最終的にはボタンをタップすると状態を切り替えられる以下のようなものが実装できるようになります。

環境
iOS 18.0 〜
Controlを追加する
まずWidget ExtensionでWidgetを追加します。

追加する際に「Include Control」にチェックを入れます。

とりあえずこれだけでシステムスペース(コントロールセンター、ロック画面)にコントロールが表示されるよになります。
コントロールセンターからコントロールを追加できる様子

追加するとコントロールセンターに表示される

ここまでがアプリにControlを追加する手順です。
タップ状態を保持してControlに反映させる
続いては実装していきます。
基本的な構成
上記の手順によりいくつかのファイルが生成されますが、その中でXXControlというファイルを開くとControlWidgetに準拠したstructが生成さているのでそのファイルを見ていきます。
struct HogeControl: ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(
kind: "Hoge",
provider: Provider()
) { value in
ControlWidgetToggle(
"Hoge Timer",
isOn: value,
action: StartTimerIntent()
) { isRunning in
Label(isRunning ? "On" : "Off", systemImage: "timer")
}
}
.displayName("Hoge")
.description("A an example control that runs a timer.")
}
}
上から順に簡単に説明していきます。
-
kind:基本的にはWidgetと同じく
Controlを識別するための文字列です。 -
provider:省略可能な引数で、
ControlValueProviderもしくはAppIntentControlValueProviderに準拠したProviderというstructを渡します。 Providerを渡すことでControlが更新された時点でcurrentValueの値が渡されす。その値はvalueとしてクロージャーで渡されます。 -
ControlWidgetToggle:
Controlの表示にあたるContentです。ControlWidgetではControlWidgetToggleとControlWidgetButtonの2種類があり、どちらかを定義する必要があります。今回はControlWidgetToggleをベースに説明します。- 第一引数:
Controlで表示される文字列です。コントロールセンターに配置時に2×2で表示した場合に表示される文字列です。 - 第二引数:トグルの状態です。初期コードでは
Providerから受け取ったものを反映しています。 - 第三引数:
Controlをタップした時に発火するアクションです。
- 第一引数:
-
ControlWidgetToggleのクロージャにあるLabel:第一引数で表示される文字列の上に表示されるシンボルアイコンと第一引数で表示される文字列の下に表示される文字列です。シンボルアイコンは1×1の表示時にやロック画面にも表示されます。
-
displayName:
Controlを追加する際に表示される文字列です。 -
description:
Controlの構成中に表示される文字列です。
実装
初期のコードではcurrentValueがtrueになるのでControlをタップしてもすぐにtrueに戻る状態となっています。
ここをローカルで保持して、状態を変更できるように修正してきます。
まず状態を管理、変更できる当なクラスを作ります。
final class TimerManager {
static let shared = TimerManager()
private init() {}
private(set) var isRunning = false
func setRunning(_ running: Bool) {
self.isRunning = running
}
}
続いてStartTimerIntentのperformで状態を変更する処理を追加します。
func perform() async throws -> some IntentResult {
TimerManager.shared.setRunning(value)
return .result()
}
currentValueで現在の状態を受け取るように修正します。
func currentValue() async throws -> Bool {
TimerManager.shared.isRunning
}
これでControlをタップする度に状態が変更、保持されます。もう少し見やすく可視化したい方は適当にLabelを修正してください。
Label(isOn ? "On" : "Off", systemImage: isOn ? "gauge.with.needle.fill" : "gauge.with.needle") // コントロールを表示する際のシンボル画像や、タイトル下に表示する文字を定義
.controlWidgetStatus(isOn ? "ON" : "OFF") // 操作後に一瞬表示されるステータスメッセージ
}
.tint(.green) //Imageの色を変更できる

以上です。
参考
Discussion