SwiftUIを使ってWidgetを実装

# はじめに

この記事は、[NTTテクノクロス Advent Calendar 2021](https://qiita.com/advent-calendar/2021/ntt-tx) 19日目です。

NTTテクノクロスの神長です。社内では、モバイル向けアプリ開発の技術支援、ノウハウ記事の執筆、[社内研修講師](https://www.ntt-tx.co.jp/products/soft_dojyo/?utm_source=qiita&utm_medium=ac2019&utm_campaign=13)等を担当してます。

また、業務外の活動は[2018年から地域の小学生を対象にしたプログラミング教育](https://www.ntt-tx.co.jp/column/p-education.html)や[社内の仲間と技術書典へ書籍の出展](https://techbookfest.org/product/5925951072370688?productVariantID=4873627981840384)等を行ってます。

今回、Widgetを題材にした記事を書こうと考えた理由は、以下の通りです。

- iOSDC 2021でWidgetに関する良いトークを視聴できたから(参考情報のセクションを参照してください)
- 社内研修(iOS)の1コンテンツとしてSwiftUIを使ったWidgetの実装を取り上げ、サンプルアプリの開発過程でノウハウ・知見を得ることができたから

これからWidgetを使ったアプリ開発を行う方のお役に立てればと思ってます。

# 1. Widgetとは

Widgetとは、ニュース、天気アプリ等に利用され、アプリを起動せずとも限られたスペースの中で、アプリの機能の一部をユーザーに提供するものです。

[WWDCのセッション：Meet WidgetKit](https://developer.apple.com/videos/play/wwdc2020/10028/)でも、 **『Glanceable、Relevant、Personalized』** といったキーワードをあげ、特徴が強調されていました。

- Glanceable:見てすぐに欲しい情報を手に入れられる
- Relevant:ユーザーの関心がある情報を適切な時間に表示する
- Personalized:利用するユーザーに合わせたカスタマイズができる

iOS14からWidgetを3種類のサイズから選び、ホーム画面上に配置ができるようになりました。(下記表は、[Apple社のHuman Interface Guidelines WidgetsのDesign](https://developer.apple.com/design/human-interface-guidelines/widgets/overview/introduction/)より引用してます)

![WidgetSize.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59827/cfcffe60-d3b4-6771-f418-e69999e59eea.png)

これによりユーザーは、より早く、価値のある/必要な情報を簡単に得られるようになりました。ここでは、Widgetについて以下事項にポイント絞って説明します。

- 開発環境
- プロジェクトへの導入方法
- サンプルアプリを例にした実装方法
- 記事執筆で参考にした情報

# 2. 開発環境 

この記事は、以下環境を使って執筆を行いました。

- Xcode：Version 13.1 (13A1030d)
- Swift：5.5.1
- iOS：15.0

# 3. プロジェクトへの導入方法

**『CoreDataWidgetSample』** というサンプルアプリのプロジェクトにWidgetを導入する手順を説明します。CoreDataWidgetSampleは、ToolBarからToDoの追加、追加したToDoの表示、削除が行えるアプリです。

![CoreDataWidgetSample.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59827/95ae3126-a282-e593-2f91-74a40ced21ee.png)

## 3-1. Widgetの追加

以下手順でWidgetを追加します。

- Xcodeでアプリケーションのプロジェクトを開き、[ファイル]＞[新規作成]＞[ターゲット]を選択してください。
- Application Extension（アプリケーション拡張）グループから、Widget Extension（ウィジェット拡張）を選択し、Nextをクリックしてください。

![Widget1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59827/20fd4346-778a-4887-f3c4-9f9a9cd3cf91.png)

- 拡張機能の名前を入力してください。(ここでは **『SampleWidget』** としてます)

![Widget2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59827/fc749842-53ee-8682-88be-32b61dc76093.png)

- Finishボタンをクリックしてください。
- プロジェクトツリー上に **『SampleWidgetExtension(フォルダ)』** が生成されることを確認してください。

![Widget3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59827/bc95dced-acec-5eef-9b56-9cec07057fb9.png)

## 3-2. AppGroupsの設定

Widgetを追加後、App Groupsの設定します。App Groupsは、iOS8から提供された機能です。
同一開発者が開発したアプリ間で共有領域にデータを保存 **※1** することで複数のアプリ間でデータの読み書きが行えるものです。

Widgetの開発では、アプリとWidget間でデータの読み書きが発生するため、この機能を利用します。

**※1 UserDefaults、ファイルを利用してアプリ間でのデータ共有ができます。**

- アプリのTARGETS＞Singing & Capabilitiesを選択してください。
-  **『+Capability』** を押下し、以下画面を表示して、その中から **『App Groups』** を選択してください。

![AppGroups1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59827/53fced91-408a-4749-5641-575ff1a1bb6d.png)

- App Groupsの枠内の下部にある＋ボタンを押下すると以下の画面が表示されるので、containerの設定を行ってください。(ここでは、group.com.ntttx.CoreDataWidgetSample.SampleWidgetと入力)

![AppGroups2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59827/154b5e59-c6ba-5e22-c21b-83f7624db892.png)

- 以下設定がなされていることを確認してください。

![AppGroups3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59827/09f60ee9-8c40-d5c2-a586-2f394a2efe57.png)

- この設定をWidgetのTARGETSに対しても行ってください。containerには、 **必ずアプリ同じ識別子を設定** してください。

# 4. サンプルアプリを例にした実装方法

WidgetでアプリがCoreDataに保存しているデータを表示する場合を例に、Widgetのテンプレートソースコード(SampleWidget.swift) **※1** の解説を含め、実装方法を説明します。

![WidgetSample1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59827/0b087403-1d7e-2f8c-2b34-98e85463ab8d.png)

**※1 この記事では、Widget Extensionに『SampleWidget』という名称を設定した為、テンプレートのソースコードが『SampleWidget.swift』となっています。**

## 4-1. テンプレートのソースコード

テンプレートのソースコードは、プロジェクトにWidget Extensionを追加することで生成されます。
生成されたソースコードには、WidgetKitがインポートされ、Widgetを起動させる為の最低限のソースコードが実装されてます。
ここでは、テンプレートのソースコードに実装されている構造体、関数の役割を説明します。

```swift
import WidgetKit
import SwiftUI
import CoreData

struct Provider: TimelineProvider {
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date())
    }

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

    func getTimeline(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)
            entries.append(entry)
        }

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

struct SimpleEntry: TimelineEntry {
    let date: Date
}

struct SampleWidgetEntryView : View {
    var entry: Provider.Entry

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

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

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            SampleWidgetEntryView(entry: entry)
        }
        .configurationDisplayName("My Widget")
        .description("This is an example widget.")
    }
}

struct SampleWidget_Previews: PreviewProvider {
    static var previews: some View {
        SampleWidgetEntryView(entry: SimpleEntry(date: Date()))
            .previewContext(WidgetPreviewContext(family: .systemSmall))
    }
}
```

### 4-1-1. Widgetのエントリーポイント

以下ソースコードは、SampleWidgetがプログラムで最初に実行されるエントリーポイントになります。

bodyプロパティで`StaticConfiguration` **※1** を生成してます。

- kind : Widgetの識別子
- provider : Widgetの更新タイミング(TimeLine **※2** )をWidgetKitに提供するプロバイダ

configurationDisplayNameに設定したテキストは『Widgetの名称』、
descriptionに設定したテキストは、Widget Gallary(Widgetの追加画面)に表示される『Widgetの説明』になります。

`SampleWidgetEntryView`は、Widgetの画面を定義する構造体です。Widgetの画面に表示する内容を変更する場合、この構造体を編集します。

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

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            SampleWidgetEntryView(entry: entry)
        }
        .configurationDisplayName("My Widget")
        .description("This is an example widget.")
    }
}
```

``` swift
struct SampleWidgetEntryView : View {
    var entry: Provider.Entry

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

**※1** SampleWidgetは`Widget`プロトコルに準拠する意味を持ち、`WidgetConfiguration`プロトコルのインスタンス(body)を返す必要があります。
WidgetConfigurationの型には、`IntentConfiguration`と`StaticConfiguration`があります。今回のサンプルでは、`StaticConfiguration`で実装を行ってます。

- IntentConfiguration：ユーザ設定可能なプロパティを持たないWidgetを実装する際に利用する型
- StaticConfiguration：ユーザーが設定可能なプロパティを持つWidgetを実装する際に利用する型

IntentConfigurationを利用する場合、Widget Extensionをプロジェクトに導入時に **『Include Configuration Intentのチェックボックスをオン』** にする必要があります。

**※2** 詳細は『TimelineProvider』のセクションを参照してください。

### 4-1-2. TimelineProvider

TimelineProvider(プロトコル)は、Widgetの更新タイミングを提供します。
そして、TimelineProviderには、以下3つの関数があります。ここでは、その詳細を説明します。

- placeholder
- getSnapshot
- getTimeline

``` swift
struct Provider: TimelineProvider {
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date())
    }

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

    func getTimeline(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)
            entries.append(entry)
        }

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

#### 4-1-2-1. placeholder

placeholderは、Widgetの初期表示を行う関数です。
テンプレートのソースコードでは、`TimelineEntry`に準拠した`SimpleEntry`を呼び出し、現在時刻を表示する実装となってます。

``` swift
func placeholder(in context: Context) -> SimpleEntry {
    SimpleEntry(date: Date())
}
```

#### 4-1-2-2. getSnapshot

getSnapshotは、Widgetをホーム画面に追加時、Widget Gallaryでの画面に表示するデータを作成する関数です。
テンプレートのソースコードでは、`TimelineEntry`に準拠した`SimpleEntry`を呼び出し、現在時刻を表示する実装となってます。

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

![WidgetGallary.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59827/59842a56-2db0-bbf9-f704-0104cdab666c.png)

#### 4-1-2-3. getTimeline

getTimelineは、WidgetKitへタイムラインを提供する関数です。
テンプレートのソースコードでは、現在時刻から1時間毎に4時間後まで、`SimpleEntry`を呼び出し、配列(entries)に追加。
その後、Timelineインスタンスを生成し、completion(クロージャー)を呼び出すことでWidgetKitにタイムラインを提供する実装となってます。([Keeping a Widget Up To Date](https://developer.apple.com/documentation/widgetkit/keeping-a-widget-up-to-date)から更新イメージを引用してます)

``` swift
func getTimeline(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)
        entries.append(entry)
    }

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

![WidgetUpdate.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59827/bcb82d18-dc16-e72c-1ff5-ec4e315e227a.png)

なお、Timelineとは、WidgetのViewをどのように更新するかを決める型です。引数は以下の通りです。

- entries : タイムラインを構成するデータ(配列)
- policy : タイムラインの更新ポリシー(ポリシーには、`.atEnd`、`.after`、`.never`の3種いずれかを指定できる **※1** )

**※1** `.atEnd`、`.after`、`.never`の詳細は以下を参照してください。

- .atEnd ：デフォルトの更新ポリシー。タイムラインを構成するデータ全てを表示し終えた時、再度getTimelineを呼び出す。
- .after(_ date: Date) ：タイムラインを構成するデータ全てを表示し終えたかを問わず、Date型の引数で指定した時刻にgetTimelineを呼び出す。
- .never :タイムラインを構成するデータ全てを表示し終えた後、再度getTimelineを呼び出さないポリシー。

### 4-1-3. プレビュー

SwiftUIのプレビュー機能は、Widgetでも利用できます。

また、WidgetPreviewContextでWidgetのサイズを指定することができます。サイズは以下3種を指定できます。デフォルトのソースコードでは『.systemSmall』が指定されてます。

- .systemSmall：Smallサイズ
- .systemMedium：Mediumサイズ
- .systemLarge：Largeサイズ

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

![WidgetPreview.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59827/0ea1bdc4-fad0-a26d-9e2a-48297d6733c3.png)

## 4-2. 実装方法

今回のサンプルでは、ファイル(CoreData)を利用してアプリとWidget間のデータの共有を行ってます。
ここでは、以下ポイントに絞り、説明します。

- Target Membershipの設定
- CoreDataのテンプレートファイルの変更
- WidgetテンプレートファイルからCoreDataを利用する設定

### 4-2-1. Target Membershipの設定

**Widget(SampleWidgetExtension)からCoreDataを利用する為** 、.xcdatamodeld(CoreDataのテーブル構造を定義するファイル)とCoreDataのテンプレートファイル(Persistence.swift)の **Target Membership** に **Widget(SampleWidgetExtension)** を含める設定を行います。

![TargetMembership.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59827/91f14c2a-1a73-9c87-46ba-220ae4a1b102.png)

### 4-2-2. CoreDataのテンプレートファイルの変更

AppGroupsを利用したデータ共有を行うため、CoreDataのテンプレートファイル(Persistence.swift)の`PersistenceController`に
`FileManager.default.containerURL(AppGroupsに設定したID).appendingPathComponent(ファイル名)`をAppGroups用のURLを生成します。
生成後、NSPersistentContainerの設定を行います。

``` swift
struct PersistenceController {
    // 中略
    // AppGroups用のURLを生成
    let appGroupContainerURL = FileManager.default
        .containerURL(forSecurityApplicationGroupIdentifier: "group.com.ntttx.CoreDataWidgetSample.SampleWidget")!
    let storeURL = appGroupContainerURL.appendingPathComponent("Task")
 
    // NSPersistentContainerの設定
    let container = NSPersistentContainer(name: "CoreDataWidgetSample")
    container.persistentStoreDescriptions = [NSPersistentStoreDescription(url: storeURL)]
    container.loadPersistentStores(completionHandler: { storeDescription, error in
        if let error = error as NSError? {
            // 任意のエラー処理を実装する
        }
    })
}
```

### 4-2-3. WidgetテンプレートファイルからCoreDataを利用する設定

Widgetテンプレートファイル(SampleWidget.swift)からCoreDataを利用する場合、先ずWidgetのエントリーポイントに以下定義を行います。

- NSPersistentContainer **※1** の定義
- @Environment(\.managedObjectContext)にNSManagedObjectContext **※2** の登録処理

**※1 NSPersistentContainerは、管理対象オブジェクトモデル（NSManagedObjectModel）、永続ストアコーディネーター（NSPersistentStoreCoordinator）、管理対象オブジェクトコンテキスト（NSManagedObjectContext）を使い、CoreDataスタックの作成と管理を行うもの。**

**※2 レコードの操作(CoreDataからのデータをフェッチ、作成、変更、削除)を管理するもの。**

``` swift
@main
struct SampleWidget: Widget {
    let kind: String = "SampleWidget"
    // NSPersistentContainerの定義
    let persistenceController = PersistenceController.shared

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider(context: PersistenceController.shared.managedObjectContext)) { entry in
            // @Environment(\.managedObjectContext)にNSManagedObjectContextの登録処理
            SampleWidgetEntryView(entry: entry).environment(\.managedObjectContext, PersistenceController.shared.managedObjectContext)

            
        }
        .configurationDisplayName("Sample Widget")
        .description("This is an sample widget.")
    }
}
```

続いて、CoreDataを参照する処理(`NSFetchRequest`)を実装します。ここでは`getTimeline`で『CoreDataから昇順でソートした検索結果を取得する』例を掲載します。

なお、Widgetではプロパティラッパー(`@FetchRequest`) **※1** は利用できないので注意が必要です。

``` swift
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
    // 中略
    var task:[Task]?

    // CoreDataから昇順でソートした検索結果を取得("time":タイムスタンプで昇順)
    let request = NSFetchRequest<Task>(entityName: "Task")
    request.sortDescriptors = [NSSortDescriptor(key: "time", ascending: true)]
 
    do{
        let result = try db.fetch(request)
        task = result
    }
    catch let error as NSError{
        print("fetch failed.\(error.userInfo)")
    }

    // ここから取得したデータを利用し、開発者が行いたい処理を実装する
}
```

**※1 以下定義をすることで、プロパティに検索結果の格納とデータの変更に合わせ検索結果が最新化される仕組み。**

``` swift
@FetchRequest(
    entity: Task.entity(),
    sortDescriptors: [NSSortDescriptor(keyPath: \Task.time, ascending: true)],
    predicate: nil
) private var tasks: FetchedResults<Task>
```

# 5. 記事執筆で参考にした情報

本記事を執筆する上で参考にした書籍、サイトを掲載します。

- [Meet WidgetKit](https://developer.apple.com/videos/play/wwdc2020/10028/)
- [Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/widgets/overview/introduction/)
- [WidgetKit](https://developer.apple.com/documentation/widgetkit)
- [Creating a Widget Extension](https://developer.apple.com/documentation/widgetkit/creating-a-widget-extension)
- [Keeping a Widget Up To Date](https://developer.apple.com/documentation/widgetkit/keeping-a-widget-up-to-date)
- [iOSDC2021 WidgetKitで良い体験を作るには](https://speakerdeck.com/tattn/good-experience-with-widgetkit)
- [iOSDC2021 WidgetKitで良い体験を作るには(YouTube)](https://www.youtube.com/watch?v=9KOCwj9BMD0)
- [Accessing Core Data from both container app and extension](https://stackoverflow.com/questions/41684256/accessing-core-data-from-both-container-app-and-extension)
- [Share data between main App and Widget in SwiftUI for iOS 14](https://stackoverflow.com/questions/63922032/share-data-between-main-app-and-widget-in-swiftui-for-ios-14/63925053#63925053)

# 最後に

最後まで読んで頂き、ありがとう御座います。
20日目となる明日の記事も楽しみにして頂ければと思います。