【Swift】Widgetの作り方 〜iOS 16対応版〜

この記事は[レコチョク Advent Calendar 2022](https://qiita.com/advent-calendar/2022/recochoku)の19日目の記事となります。

# はじめに
最近チェンソーマンのエンディングテーマばかり聞いている神山です。
株式会社レコチョクでiOSアプリ開発をしています。

先月になりますが、ホーム画面に配置できるウィジェットをタワーレコード株式会社と弊社が共同で開発してるTOWER RECORDS MUSICという音楽サブスクリプションサービスのiOSアプリに導入しました。

https://music.tower.jp/

ということで今回は、ウィジェットの概要、実装方法についてご紹介しようと思います。

## ウィジェットとは
ウィジェットとはアプリを起動せずとも、ユーザーの関心のあるコンテンツや一目で分かる情報を表示し、素早くアクセスして詳細を確認できるようにしたものです。

ウィジェットはさまざまなサイズで作成でき、ユーザーは自分にとって重要な情報をホーム画面、iOS16からはロック画面にも表示できるようになりました。

ホーム画面 | ロック画面
:--: | :--:
![widget_home.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2957574/5d20e865-e18e-3f3d-83a6-6bdfbe3d4475.png) | ![widget_lock.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2957574/7ec19bc3-6ef8-4cce-63c0-c2212a356a2d.png)

## 開発環境
* Xcode: 14.0.1
* iOS: 16.0

## 実装方法
実際にアプリを作成してウィジェットの実装方法を確認してみます。

### Widget Extensionの追加
まずはじめにターゲットにウィジェットを作成するためのテンプレートとして「Widget Extension」を作成します。
以下の順番で作成できます。

1. 「File」→「New」→「Target」を選択
2. 「Widget Extension」を選択し「Next」を選択
3. 「Widget Extension」の名前を入力(ここではQiitaWidgetExtensionにします)
4. 「Include Configuration Intent」のチェックボックスを必要に応じてチェックを入れる
5. 「Finish」を選択し作成完了

![widget_create.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2957574/2cb1b891-6e6a-6b36-a25b-16901e940cdc.png)

※ 「Include Configuration Intent」にチェックを入れると、ユーザーが自らウィジェットをカスタマイズできるようになる設定ファイルを作成と同時に追加できます。

カスタマイズできるウィジェットを長押しすると、「ウィジェットを編集する」という項目を選択できるようになります。

例えば、現在時刻を表示する地域を指定する時計ウィジェットや都市の郵便番号が必要な天気ウィジェット、追跡番号が必要な荷物追跡ウィジェットが挙げられます。

![widget_configuration.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2957574/9f4c92f3-e79d-ed82-40c2-885f48574e62.png)

### ウィジェットの構成要素
Widget Extensionを追加するだけで、現在時刻を表示するウィジェットを追加できるファイルが自動生成されます。(QiitaWidgetExtension.swift)

![widget_template.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2957574/e9333448-51d5-8f26-efdc-3c78ee7f15b7.png)

このテンプレートにウィジェットを生成するための構成要素が全て記載されていますので順に確認してみましょう。

以下、自動生成されたソースコードになります。

``` swift
import WidgetKit
import SwiftUI
import Intents

struct Provider: IntentTimelineProvider {
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date(), configuration: ConfigurationIntent())
    }

    func getSnapshot(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (SimpleEntry) -> ()) {
        let entry = SimpleEntry(date: Date(), configuration: configuration)
        completion(entry)
    }

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

        // Generate a timeline consisting of five entries an hour apart, starting from the current date.
        let currentDate = Date()
        for hourOffset in 0 ..< 5 {
            let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
            let entry = SimpleEntry(date: entryDate, configuration: configuration)
            entries.append(entry)
        }

        let timeline = Timeline(entries: entries, policy: .atEnd)
        completion(timeline)
    }
}

struct SimpleEntry: TimelineEntry {
    let date: Date
    let configuration: ConfigurationIntent
}

struct QiitaWidgetExtensionEntryView : View {
    var entry: Provider.Entry

    var body: some View {
        Text(entry.date, style: .time)
    }
}

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

    var body: some WidgetConfiguration {
        IntentConfiguration(kind: kind, intent: ConfigurationIntent.self, provider: Provider()) { entry in
            QiitaWidgetExtensionEntryView(entry: entry)
        }
        .configurationDisplayName("My Widget")
        .description("This is an example widget.")
    }
}

struct QiitaWidgetExtension_Previews: PreviewProvider {
    static var previews: some View {
        QiitaWidgetExtensionEntryView(entry: SimpleEntry(date: Date(), configuration: ConfigurationIntent()))
            .previewContext(WidgetPreviewContext(family: .systemSmall))
    }
}
```

#### @main
ウィジェットが起動した際に最初に実行されるエントリーポイントです。

ここで表示するウィジェットの各設定を行います。

``` swift
@main
struct QiitaWidgetExtension: Widget {
    let kind: String = "QiitaWidgetExtension"

    var body: some WidgetConfiguration {
        IntentConfiguration(kind: kind, intent: ConfigurationIntent.self, provider: Provider()) { entry in
            QiitaWidgetExtensionEntryView(entry: entry)
        }
        .configurationDisplayName("My Widget")
        .description("This is an example widget.")
        // .supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
    }
}
```

* 各役割説明
  * kind・・・ウィジェットの識別子。複数のウィジェットを作成した際に、どのウィジェットかを判別するために使用します。
  * IntentConfiguration・・・ユーザーがカスタマイズできるウィジェットを作成します。
  * configurationDisplayName・・・ウィジェット作成画面のタイトル名を表示します。
  * description・・・ウィジェット作成画面の説明文を表示します。
  * supportedFamilies・・・作成可能なウィジェットのサイズを指定します。指定しなければデフォルトとなり、小・中・大全てのサイズを作成できます。

![widget_description.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2957574/c5ac04ec-98d8-6d74-ec96-7338f80e9789.png)

※ ユーザーがカスタマイズできるウィジェットがIntentConfigurationに対して、カスタマイズができず表示のみを行うウィジェットの場合はStaticConfigurationを使用します。

``` swift
StaticConfiguration(kind: kind, provider: Provider()) { entry in
    QiitaWidgetExtensionEntryView(entry: entry)
}
```

#### TimelineEntry
ウィジェットにデータを表示するタイミングを教えるための日付(Date)を提供するプロトコルです。

TimelineEntryを準拠させることでウィジェット用のデータ構造体を作成できます。

``` swift
struct SimpleEntry: TimelineEntry {
    let date: Date
    let configuration: ConfigurationIntent
}
```

#### TimelineProvider(IntentTimelineProvider)
ウィジェットの更新タイミングを提供するプロトコルです。

ここで設定した更新要求に基づいてウィジェットが更新されます。

``` swift
struct Provider: IntentTimelineProvider {
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date(), configuration: ConfigurationIntent())
    }

    func getSnapshot(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (SimpleEntry) -> ()) {
        let entry = SimpleEntry(date: Date(), configuration: configuration)
        completion(entry)
    }

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

        // Generate a timeline consisting of five entries an hour apart, starting from the current date.
        let currentDate = Date()
        for hourOffset in 0 ..< 5 {
            let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
            let entry = SimpleEntry(date: entryDate, configuration: configuration)
            entries.append(entry)
        }

        let timeline = Timeline(entries: entries, policy: .atEnd)
        completion(timeline)
    }
}
```

* placeholder
  * getTimelineのcompletionが呼ばれるまで、代わりに表示するデータを設定します。
  * この設定されたデータに基づいてスケルトンビューのようなものを表示できます。

![widget_skelton.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2957574/7e05497a-b0af-4b52-baed-8b41bb92615e.png)

* getSnapshot
  * ウィジェット作成画面で表示する際のデータを設定します。

![widget_snapshot.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2957574/f3365d39-0f12-e1e6-725a-91b5d9ee421c.png)

* getTimeline
  * ウィジェットを作成、更新後に表示するデータを設定します。

![widget_timeline.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2957574/cf9ca996-a56e-b3d7-12b4-1cdf67c9ede7.png)

### ユーザーがカスタマイズできるウィジェット
IntentConfigurationを設定すればウィジェットをカスタマイズできますが、デフォルトでは何も指定してないためカスタマイズできません。

今回は一例として、編集した際に候補として表示した文言をウィジェットに表示できるようにしてみます。

QiitaWidgetExtension.intentdefinitionファイルに対して新たに設定を追加する必要があり、以下が実行手順になります。

※ Configurationの命名をQiitaConfigurationに変更しています

1. Enumの追加(QiitaのEnumを作成しました)、各Caseに値を設定(qiita1, qiita2, qiita3のcaseを作成しました)

![widget_enum.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2957574/0f81b7f9-81ed-898f-ae7e-2ddf3627512c.png)

1. ConfigurationのParameterに値を追加(qiitaParameterを作成しました)
2. qiitaParameterのTypeに先ほど作成したEnumのQiitaを適用
3. Configurable[User can edit value in Shortcuts, widgets, and Add to Siri]にチェックを入れ、デフォルト値を設定

![widget_parameter.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2957574/f6b0156b-3f4d-a4c5-b753-aa790e7e5e68.png)

これらの設定が完了すると、「ウィジェットを編集」の選択ができるようになり、カスタマイズ可能なウィジェットを作成できます。

![widget_edit.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2957574/4d921c4b-541b-1b1c-01af-fcea354d41d1.png)

### ロック画面のウィジェット
iOS 16からはロック画面にもウィジェットを配置できます。

ロック画面には3種類のウィジェットを追加でき、ロック画面用のWidgetFamily(.accessoryInline, .accessoryRectangular, .accessoryCircular)をsupportedFamiliesに追加するだけになります。

![widget_ios16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2957574/5255eefb-22bf-eefa-edfa-056d793677be.png)

# まとめ
ウィジェットはユーザーがアプリを起動せずともコンテンツを表示でき、アプリのUX向上につながる便利な機能の1つです。

ウィジェットの概要・実装方法について間単にまとめてみました。導入の一助となれば幸いです。

最後まで読んでいただきありがとうございました。

明日の[レコチョク Advent Calendar](https://qiita.com/advent-calendar/2022/recochoku)は20日目「DAOって何？初心者におすすめのDAOを紹介」です。お楽しみに！

# 参考文献
[Creating a Widget Extension](https://developer.apple.com/documentation/widgetkit/creating-a-widget-extension)

[Creating Lock Screen Widgets and Watch Complications](https://developer.apple.com/documentation/widgetkit/creating-lock-screen-widgets-and-watch-complications)

***
この記事はレコチョクのエンジニアブログの記事を転載したものとなります。

https://techblog.recochoku.jp/8789