iOS の widget を設定可能にする

## 概要

iOS 14 からホーム画面に widget が置けるようになりました。widget には設定可能なものとそうでないものの２種類があります。

設定可能なものの例として Apple の天気 widget があります。天気 widget を長押しして出てくる「ウィジェットを編集」をタップすると編集画面が開き、天気を表示する場所を設定することができます。天気の widget を追加する上ではどの場所の天気を見たいのかをユーザが決めたいでしょうから、この項目が指定可能になっているのは自然に思えます。

設定可能でないものの例としては Apple のマップ widget があります。こちらは widget を長押ししても「ウィジェットを編集」という項目が出てきません。これは、マップ widget では天気と異なりユーザがなにかを設定する余地がないように設計されているからでしょう。

widget を自作する上では、作りたい widget の性質を鑑みて widget を設定可能にするかどうか決める必要があります。この記事では、widget を設定可能にする方法をまとめます。まず設定可能でない widget を作ってそれを設定可能にしていくという手順を取りたいと思います。最初から設定可能な widget を作ることもできますが、設定可能でない widget に手を加えていく方がどの操作によって設定可能になるのかが理解しやすいためです。

検証環境は以下です。記事中に Xcode のスクリーンショットを貼っていますが、Xcode のアップデートに伴い UI が変わるであろうことに注意してください。

- Xcode 13.2
- iOS 15.2.1

この記事は主に以下の WWDC セッションの内容を元に書かれています。時間に余裕のある方は、この記事を読むよりもセッションの動画を見た方がよいと思います。

https://developer.apple.com/videos/play/wwdc2020/10194/

## 設定可能でない widget を作る

まず最初に設定可能でない widget を作るところからはじめます。Xcode から適当な iOS のプロジェクトを生成して File -> New -> Target から widget extension を追加します。以下のような画面が出てくると思いますが、 `Include Configuration Intent` のチェックははずしておきましょう。ちなみに、チェックをつけておくとはじめから設定可能な widget を作ることができます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/159260/d3445590-1a1a-d298-a409-e37530d455ab.png)

例として、生成された widget を少し変更して、ランダムな数値を表示する widget を作っていきましょう。ランダムな数値でも見てちょっと落ち着きたいというときに便利です。

まず widget が受け取って表示に使うデータである `SimpleEntry` のプロパティにランダムな数値を追加します。名前も変えて以下のようにします。

```swift
struct RandomNumberWidgetEntry: TimelineEntry {
    let date: Date
    let number: Int
}
```

entry を widget に渡す `Provider` も合わせて変更します。entry に数値のためのプロパティが追加されたのでそれに追従するだけで大丈夫です。ランダムな数値としては 1 から 100 の間の数値を `Int.random` で生成したものを使います。

```swift
struct RandomNumberWidgetProvider: TimelineProvider {
    func placeholder(in context: Context) -> RandomNumberWidgetEntry {
        RandomNumberWidgetEntry(date: Date(), number: 42)
    }

    func getSnapshot(in context: Context, completion: @escaping (RandomNumberWidgetEntry) -> ()) {
        let entry = RandomNumberWidgetEntry(date: Date(), number: getRandomNumber())
        completion(entry)
    }

    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        var entries: [RandomNumberWidgetEntry] = []

        let currentDate = Date()
        for hourOffset in 0 ..< 5 {
            let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
            let entry = RandomNumberWidgetEntry(date: entryDate, number: getRandomNumber())
            entries.append(entry)
        }

        let timeline = Timeline(entries: entries, policy: .atEnd)
        completion(timeline)
    }
    
    private func getRandomNumber() -> Int {
        Int.random(in: 1...100)
    }
}
```

最後に widget の view を書きます。受け取った数値をでかでかと表示しておきます。

```swift
struct RandomNumberWidgetEntryView : View {
    var entry: RandomNumberWidgetProvider.Entry

    var body: some View {
        VStack {
            Text("\(entry.number)")
                .font(.largeTitle)
            
            Text(entry.date, style: .time)
        }
    }
}

@main
struct Widgets: Widget {
    let kind: String = "Widgets"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: RandomNumberWidgetProvider()) { entry in
            RandomNumberWidgetEntryView(entry: entry)
        }
        .configurationDisplayName("Random Number Widget")
        .description("This widget shows random number")
    }
}
```

実行すると、以下のような widget が表示されます。ちゃんとランダムな数値が表示されていますね。この時点では設定可能にしていないので長押ししても「ウィジェットを編集」の項目が出てこないようになっています。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/159260/cf2583fe-a074-dd32-925a-f11f52c7bfed.png" width="300">

ここまでのコードは https://gist.github.com/maiyama18/73fb89086d790db07476d37b8a133687 にあります。

## widget を設定可能にする

この widget を設定可能にしましょう。現状だとランダムな数値の最大値が決め打ちで 100 になっていますが、これをユーザが設定できるようにします。

### intentdefinition の追加

widget の設定には intent という仕組みを使います。まず、設定項目を記述するための intentdefinition ファイルを作成します。File -> New -> File で検索窓に intent と打ち込むと `SiriKit Intent Definition File` が出てくるのでそれを作成します。

作成した intentdefinition ファイルを開いて、左下の + -> New Intent から intent を作成します。intent の名前はなんでもよいですが、今回は `RandomNumber` としておきましょう。

widget からこの intent を利用するために、設定項目の Custom Intent に含まれる `Intent is eligible for widgets` にチェックを入れておきます。 Siri からこの Intent を使うわけではないので、その下の `Intent is user-configureble...` と `Intent is eligible for Siri Suggestions` のチェックは外しておきます。また、Category は intent の目的に Do から View に変更しておきます。おそらく widget に使うだけなら Category は動作に影響ないと思うのですが、記事冒頭で挙げた WWDC のセッションでそのように変更していたので従うのがよいでしょう。

設定項目の追加は Parameters の項目の左下の + から行います。今回追加したい設定項目はランダムな数値の最大値なので `maxNumber` という名前で追加します。追加したら、Type を `Integer` に変更し、`Siri can ask for value when run` のチェックをはずしておきます。右下の Input からこの設定値が取りうる範囲とデフォルト値を設定できるので、`Default Value` を 100、`Minimum Value` を 1、`Maximum Value` を 200 にしておきます。`Type` で値を設定する UI を指定することができ、 `Integer` 型の場合は Field / Stepper から選びます。どちらでもよいですが、今回は Stepper にしましょう。

ここまでで、intentdefinition ファイルは以下のようになっていると思います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/159260/9ebe9412-3d98-54d7-cdce-3c8b88a54a5a.png)


注意すべき点として、作成した intentdefinition ファイルは Target Membership の設定からアプリ本体と widget extension の両方のターゲットに入れておきましょう。自分が試したかぎり、アプリ本体に追加しなくてもコンパイルは通りすが実行して「ウィジェットを編集」したときに設定項目が取得できず空白の設定画面が表示されるということが起こりました。

### widget を IntentConfiguration に変更

続いて、作成した intent に紐づく設定を widget から見るように変更します。widget 作成時に必要な WidgetConfiguration には、設定可能でない widget を作るための `StaticConfiguration` と設定可能な widget を作るための `IntentConfiguration` の２つがあります。現状のコードを見てみると、`StaticConfiguration` が使われていることがわかると思います。これを設定可能な `IntentConfiguration` に置き換えます。

`StaticConfiguration` を `IntentConfiguration` に書き換えるとコンパイルエラーが出ると思います。まず、 Configuration の init のシグネチャが変わっているため、それに合わせて `IntentConfiguration` 生成時に使う intent の型を渡してあげます。

```swift
    var body: some WidgetConfiguration {
        IntentConfiguration(kind: kind, intent: RandomNumberIntent.self, provider: RandomNumberWidgetProvider()) { entry in
            RandomNumberWidgetEntryView(entry: entry)
        }
    }
```

先ほど作成した RandomNumber という intent を元に生成された `RandomNumberIntent` という型を指定しています。この swift の型はビルド時に intentdefinition ファイルから自動生成されているようで、 intentdefinition ファイルの方で RandomNumber の定義を変更してビルドすると `RandomNumberIntent` に反映されるようになっています。

この時点でもまだコンパイルエラーが出ています。エラーメッセージを見ると、 `RandomNumberWidgetProvider` を `IntentTimelineProvider` にしないといけないことがわかります。設定可能にするためには widget にデータを渡す Provider の型も変更しないといけないということですね。

`TimelineProvider` を `IntentTimelineProvider` に書き換えると、また別のコンパイルエラーになります。これは、 `getSnapshot` と `getTimeline` のシグネチャが変わったためで、具体的にはどちらも第１引数で intent を受け取るようになっています。intent にはユーザが設定した内容が含まれているため、その設定を見て widget に渡すデータを決めるというわけです。以下のように書き換えます。

```swift
struct RandomNumberWidgetProvider: IntentTimelineProvider {
    func placeholder(in context: Context) -> RandomNumberWidgetEntry {
        RandomNumberWidgetEntry(date: Date(), number: 42)
    }

    func getSnapshot(for configuration: RandomNumberIntent, in context: Context, completion: @escaping (RandomNumberWidgetEntry) -> ()) {
        let entry = RandomNumberWidgetEntry(date: Date(), number: getRandomNumber(configuration: configuration))
        completion(entry)
    }

    func getTimeline(for configuration: RandomNumberIntent, in context: Context, completion: @escaping (Timeline<RandomNumberWidgetEntry>) -> ()) {
        var entries: [RandomNumberWidgetEntry] = []

        let currentDate = Date()
        for hourOffset in 0 ..< 5 {
            let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
            let entry = RandomNumberWidgetEntry(date: entryDate, number: getRandomNumber(configuration: configuration))
            entries.append(entry)
        }

        let timeline = Timeline(entries: entries, policy: .atEnd)
        completion(timeline)
    }
    
    private func getRandomNumber(configuration: RandomNumberIntent) -> Int {
        let maxNumber = configuration.maxNumber?.intValue ?? 100
        return Int.random(in: 1...maxNumber)
    }
}
```

これまで決め打ちで 1 から 100 の間の値を生成していた `getRandomNumber` に intent を渡し、ユーザが設定した最大値である `maxNumber` までの値を生成するようにしました。intent に含まれる `maxNumber` は `NSInteger?` なので `Int` に変換しています。

ここまでで変更したコードを実行してみましょう。うまくいっていれば、 widget を長押しすると「ウィジェットを編集」の項目が出てきます。これをタップすると、`maxNumber` を設定する UI が表示されます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/159260/ecf2fa22-f754-e011-b662-ff1ed7ca0f2c.png" width="300" />

適当な値を設定してからホーム画面に戻るとちゃんと最大値が反映されていることが確認できます。例えば `maxNumber` を 1 にすると widget には 1 が表示されるし、200 にすると（運が良ければ）101 以上の値が表示されます。

この項でのコードの変更は https://gist.github.com/maiyama18/cd2695a87e30aae716f1a2c3f386a1b5/revisions にあります。

### 選択肢を動的に提供する

前の項で追加した設定はすべてのユーザに対して同じ静的なものです。つまり、ランダムな数値の最大値をどのユーザに対してもどんなときでも 1 から 200 の間から選ばせます。ユーザが設定自体はできますが、その設定値の選択肢は固定ということです。

これに対して、現実的なアプリではユーザごとに異なる選択肢を動的に提供したいことも多いでしょう。例えば、天気 widget の設定では、表示する場所の候補として天気アプリの本体で登録している場所の一覧が表示されます。アプリで登録している場所はユーザによって違いますから、アプリと情報のやり取りをした結果をもとに選択肢を提供する必要があるということになります。

ここまで作ってきた widget でも動的に選択肢を出すようにしてみましょう。仮に、アプリ側の UI でユーザが何らかの範囲を作成しておけるとしましょう。widget ではランダムな数値を表示する範囲をその中から選べるとします。記事の主題から外れてしまうのでちゃんとは作りませんが、以下のような UI を想定しています。この状態で widget の設定を開くと「Tシャツの価格」「冷房の設定温度」の中から利用する範囲を選べるというイメージです。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/159260/a567a252-fa84-28e1-b40b-7d537fb31b67.png" width="300" />

まずはこの範囲を表すデータ構造を widget が受け取れるように intentdefinition に定義します。parameter の左下の + から `customRange` という parameter を追加します。Type に `Add Type...` という選択肢があるためそれを選択します。そうすると新しい Type が追加されるので、 `CustomRange` という名前をつけておきましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/159260/106ec58e-1589-cf61-4895-08b4d0aa0ee2.png)

このデータ構造のプロパティとして、最初から `identifier` と `displayString` が定義されていますが、これらは消したり編集したりはできないと思うのでそのまま置いておきます。範囲には

- 名前
- 最小値
- 最大値

があればよいので、名前は `displayString` に入れることにして最小値用の `minNumber` と最大値用の `maxNumber` をプロパティに追加します。どちらも Type は `Integer` にしておきます。

これで範囲を表す新しい Type ができたので、 `RandomNumber` intent の設定に戻り、 `customRange` の `Options are provided dynamically` にチェックを入れます。以上で動的に提供する選択肢の型の準備ができました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/159260/4e192aae-a0f2-7c2f-c913-de19d813969b.png)

型の情報は intentdefinition ファイルに書きましたが、実際の「Tシャツの価格」や「冷房の設定温度」という選択肢はユーザがアプリで設定するものであるため、 intentdefinition ファイルに書いておくことはできません。widget がアプリとデータをやり取りして選択肢を提供するためのコンポーネントとして IntentHandler を利用します。

まず、File -> New -> Target から Intents Extension を追加します。 `Include UI Extension` のチェックは外しておきましょう。作成された extension の中に IntentHandler というファイルがあるので、これを変更していきます。まず、生成された IntentHandler のすべての実装を消します。 intentdefinition ファイルの Target Membership の設定から Intents Extension に含めるようにすると、 IntentHandler から `RandomNumberIntentHandling` という protocol が見えるようになるので、 IntentHandler をこれに準拠させて `provideCustomRangeOptionsCollection` メソッドを実装します。`provideCustomRangeOptionsCollection` の中でなんらかの方法でアプリで設定された選択肢を取得し、`completion` に渡すことで widget の設定の UI 上から選択できるようになります。

```swift
class IntentHandler: INExtension {}

extension IntentHandler: RandomNumberIntentHandling {
    func provideCustomRangeOptionsCollection(for intent: RandomNumberIntent, with completion: @escaping (INObjectCollection<CustomRange>?, Error?) -> Void) {
        // ここでアプリで設定した範囲の一覧を取得して completion に渡す
    }
}
```

IntentHandler からアプリで設定した範囲を取得するためには、アプリと Intent Extension を同じ App Group に入れてデータをやり取りできるようにします。同じ App Group に入ると共通の UserDefaults や Keychain にアクセスできるようになるため、たとえば

- アプリ側で登録した範囲を UserDefaults に保存して IntentHandler から取得する
- セッショントークンを Keychain に入れて IntentHandler から API リクエストをする

などのやり方が考えられるでしょう。`provideCustomRangeOptionsCollection` のシグネチャで、選択肢の一覧が返り値ではなく `completion` に渡すようになっているため、 API リクエストのような非同期でのデータ取得にも対応できるようになっています。ちなみに iOS 15 からは async/await に対応した `provideCustomRangeOptionsCollection` もありますが、今回はクロージャを呼ぶパターンで実装を行います。

App Group や App Group を介したデータのやり取りについてはこの記事の主題から少し外れるので、ここでは App Group に追加し何らかの手段でデータを共有できたものと仮定して話を進めます。App Group については例えば以下のドキュメントが参考になります。

https://developer.apple.com/documentation/security/keychain_services/keychain_items/sharing_access_to_keychain_items_among_a_collection_of_apps

値の取得さえできてしまえば、IntentHandler の実装には難しいことはありません。例えば以下のようになるでしょう。

```swift
extension IntentHandler: RandomNumberIntentHandling {
    func provideCustomRangeOptionsCollection(for intent: RandomNumberIntent, with completion: @escaping (INObjectCollection<CustomRange>?, Error?) -> Void) {
        completion(INObjectCollection(items: getCustomRanges()), nil)
    }
    
    // ここでは IntentHandler 内で値を生成しているが、
    // 実際は App Group の UserDefaults などを介してアプリ側でユーザが設定した選択肢を返す
    private func getCustomRanges() -> [CustomRange] {
        let tShirtPrice = CustomRange(identifier: "t-shirt", display: "Tシャツの価格")
        tShirtPrice.minNumber = 980
        tShirtPrice.maxNumber = 4980
        
        let airConditionerTemperature = CustomRange(identifier: "temperature", display: "冷房の設定温度")
        airConditionerTemperature.minNumber = 18
        airConditionerTemperature.maxNumber = 28
        
        return [tShirtPrice, airConditionerTemperature]
    }
}
```

これでアプリを実行すると、 widget の設定に Custom Range が追加されていると思います。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/159260/dfc1a6e4-6e82-7c95-40ca-06248a084fad.png" width="300" />

「選択」をタップすると、IntentHandler が返した選択肢が表示されていることがわかります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/159260/0e95b1f6-8de0-94fb-1553-a7bcf333e96f.png" width="300" />

ただし、ここで例えば「Tシャツの価格」を選択しても widget の表示に反映されません。まだ widget 側で Custom Range を見るように実装を変更していないためです。Custome Range を見るようにするため、 `getRandomNumber` を以下のように変更します。`customRange` から最小値、最大値を取得できればそれを利用し、そうでなければこれまで通りの実装を利用します。

```swift
    private func getRandomNumber(configuration: RandomNumberIntent) -> Int {
        if let customRange = configuration.customRange,
           let minNumber = customRange.minNumber?.intValue,
           let maxNumber = customRange.maxNumber?.intValue {
            return Int.random(in: minNumber...maxNumber)
        } else {
            let maxNumber = configuration.maxNumber?.intValue ?? 100
            return Int.random(in: 1...maxNumber)
        }
    }
```

これで設定した Custom Range をもとに widget にランダムな数値が表示されるようになります。

### 特定の選択肢の値によって別の選択肢を出し分ける

現状の widget 設定の UI では Max Number と Custom Range が並んでいて、ユーザからするとどちらが使われるのかわかりづらくなっています。この問題を解決するために、Custom Range を使うかどうかという設定項目を追加し、その値によって Max Number と Custom Range を出し分けるようにします。何を言っているかわかりづらいと思うので、最初に完成形を示しておきます。これでどちらの設定が使われているのかわかりやすくなりますね。

![Simulator Screen Recording - iPhone 12 Pro - 2021-12-22 at 23.10.31.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/159260/20d3206f-e093-8577-cdd4-add5470aa428.gif)

この UI を実現するために、parameter の親子関係を利用します。Use Custom Range という parameter を追加して、Max Number と Custom Range をその子にすることで表示の切り替えができるようになります。

まず Use Custom Range を Boolean 型で以下のように追加しましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/159260/5c0a1c05-c423-d702-9868-62f984148aed.png)

続いて、Max Number と Custom Range を Use Custom Range の子にします。`maxNumber` の設定を開いて Relationship > Parent Parameter に `useCustomRange` を指定し、Show If Parent を `has exact value` にします。Max Number は Use Custom Range が false のときに利用したいので Value は `false` にしておきましょう。`customRange` の方でも同様の設定を行い、こちらは Value を `true` にします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/159260/2de4403b-c529-51e6-2a0c-c90f75d8c75a.png)

これで widget 設定の UI はうまく動作するようになったので、 widget からも Use Custom Range の値を見るようにします。`getRandomNumber` に `useCustomRange` を見る処理を１行追加するだけでよいでしょう。

```swift
    private func getRandomNumber(configuration: RandomNumberIntent) -> Int {
        if configuration.useCustomRange == true,
           let customRange = configuration.customRange,
           let minNumber = customRange.minNumber?.intValue,
           let maxNumber = customRange.maxNumber?.intValue {
            return Int.random(in: minNumber...maxNumber)
        } else {
            let maxNumber = configuration.maxNumber?.intValue ?? 100
            return Int.random(in: 1...maxNumber)
        }
    }
```

## まとめ

- widget には `StaticConfiguration` を使う設定できない widget と `IntentConfiguration` を使う設定できる widget の２種類がある
- `IntentConfiguration` を利用し intentdefinition ファイルに設定項目を追加することで、ユーザが設定した値を元に widget の表示を変えることができるようになる
- 設定項目の選択肢を動的に決めたい場合には Intent Extension を追加し、App Group によりアプリ本体とのデータのやり取りを行う。取得した選択肢を IntentHandler から返すことで widget の設定 UI で動的な選択肢を提供することができるようになる