💾

【Swift】Widget から SwiftData にアクセスする

に公開

初めに

今回は WidgetKit の Widget で表示するデータを SwiftData から取得する方法についてまとめてみたいと思います。最終的には、データ更新をすぐに Widget に反映させられるような実装を行います。

記事の対象者

  • Swift 学習者
  • Widget で SwiftData のデータを活用したい方

目的

今回は上記の通り、 Widget で SwiftData のデータを読み取って表示することを目的とします。
簡単に手順をまとめつつサンプルを作ってみたいと思います。

今回作成するサンプルは以下のリポジトリで公開しています。
よろしければ適宜ご参照ください。

https://github.com/Koichi5/widgetkit_swiftdata_sample

実装

実装は以下の手順で進めていきます。

  1. iOS プロジェクトの作成
  2. Widget Extension の追加
  3. App Groups の追加
  4. コード修正

1. iOS プロジェクトの作成

まずは iOS プロジェクトを作成していきます。

File > New > Project ... で iOS の App を選択します。

筆者の手元ではアプリの名前は「WidgetKit+SwiftData」としておきます。
また、今回は SwiftData を使用するので、 Storage の項目は SwiftData としておきます。

これでプロジェクトの作成は完了です。

2. Widget Extension の追加

次に Widget Extension の追加を行います。
File > New > Target ... で追加するターゲットを選択します。

今回は以下の画像のように Widget Extension を追加します。

筆者の手元では名前は「CustomWidget」としておきます。

Widget を追加できたら Widget Extension の scheme を Activate しておきます。

これで Widget Extension の追加は完了です。

3. App Groups の追加

次に App Groups の追加を行います。
WidgetKit+SwiftData の Signing & Capabilities を開いて、「+Capability」を押下します。
そこで「App Groups」を選択することで App Groups を追加することができます。

この App Groups の追加をアプリ側(WidgetKit+SwiftData)と Widget側(CustomWidgetExtension)の Target の両方で行います。

App Groups を両方で追加できたら紐付けを行います。
以下のようにアプリ側で新たに App Groups を作成してチェックします。

アプリの方が完了したら Widget の方も同じ App Groups にチェックを入れます。

これでアプリと Widget でデータ共有するための紐付けが完了します。

4. コード修正

最後にコード修正を行います。
コード修正は以下の流れで進めていきます。

  1. アプリ側の実装
  2. Widget 側の実装

1. アプリ側の実装

まずは Widget 側で表示させたい SwiftData のモデルのファイルのターゲットを変更する必要があります。
今回はデフォルトで作成されている Item.swift の値を Widget で表示させたいので、以下のように Target Membership の + ボタンを押します。

Target Membership に Widget のターゲット(CustomWidget)を追加します。
これで Widget 側でも Item.swift の内容を参照できるようになります。

次に Item.swift の変更を行います。
今回は変化がわかりやすいように Item に String 型の title を持たせるように変更します。

Item.swift
import Foundation
import SwiftData

@Model
final class Item {
    var title: String
    
    init(title: String) {
        self.title = title
    }
}

次に ContentView.swift の修正を行います。
Item のプロパティを変更したのでそれに応じて修正していきます。

ContentView.swift
import SwiftUI
import SwiftData
import WidgetKit

struct ContentView: View {
    @Environment(\.modelContext) private var modelContext
    @Query private var items: [Item]
    @State private var newItemTitle: String = ""

    var body: some View {
        NavigationStack {
            VStack {
                TextField(text: $newItemTitle) {
                    Text("New Item")
                }
                .padding()
                List {
                    ForEach(items) { item in
                        NavigationLink {
                            Text("Item at \(item.title)")
                        } label: {
                            Text(item.title)
                        }
                    }
                    .onDelete(perform: deleteItems)
                }
            }
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    EditButton()
                }
                ToolbarItem {
                    Button {
                        if(newItemTitle.isEmpty) { return }
                        addItem(title: newItemTitle)
                    } label: {
                        Label("Add Item", systemImage: "plus")
                    }
                }
            }
            .padding()
        }
    }

    private func addItem(title: String) {
        withAnimation {
            let newItem = Item(title: title)
            modelContext.insert(newItem)
            try? modelContext.save()
            // Widget の記録更新
            WidgetCenter.shared.reloadAllTimelines()
        }
    }

    private func deleteItems(offsets: IndexSet) {
        withAnimation {
            for index in offsets {
                modelContext.delete(items[index])
            }
            try? modelContext.save()
            // Widget の記録更新
            WidgetCenter.shared.reloadAllTimelines()
        }
    }
}

ContentView に含まれる以下のコードでは、 modelContext.save() で SwiftData のデータを保存し、reloadAllTimelines メソッドで Widget の再読み込みを行なっています。
これで Widget に表示するデータを最新に保つことができるようになります。
詳しくは こちら のドキュメントをご参照ください。

try? modelContext.save()
WidgetCenter.shared.reloadAllTimelines()

アプリ側では SwiftData のデータ追加、削除のみを行うシンプルな処理を実装しています。
これでアプリ側の実装は完了です。

2. Widget 側の実装

次に Widget 側の実装を行います。
CustomWidget/CustonWidget.swift で SwiftData のデータを表示できるように実装していきます。

以下のように、@Query で SwiftData に格納されているデータを呼び出して model に代入します。
そして、データを ForEach で表示させます。
今回はデータを参照するだけのシンプルな実装なので、必要に応じてスタイルの変更等を行います。

CustomWidget.swift
struct CustomWidgetEntryView : View {
    var entry: Provider.Entry
    @Query var model: [Item]

    var body: some View {
        VStack {
            LazyVStack(content: {
                ForEach(model, id: \.self) { item in
                    Text(item.title)
                }
            })
        }
    }
}

次に CustomWidget に含まれる CustomWidgetEntryView に対して、使用したいモデルの modelContainer を適用します。
これで Widget 側でも Item モデルのデータにアクセスできるようになります。

CustomWidget.swift
struct CustomWidget: Widget {
    let kind: String = "CustomWidget"

    var body: some WidgetConfiguration {
        AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider()) { entry in
            CustomWidgetEntryView(entry: entry)
                .containerBackground(.fill.tertiary, for: .widget)
                // modelContainer の追加
                .modelContainer(for: [Item.self])
        }
    }
}

この状態でアプリを実行し、Widget を追加してから SwiftData に含まれるデータを変更してみます。
以下の動画のように、変更がすぐに反映されるかと思います。

https://youtube.com/shorts/uaZzvjRDyhs

以上です。

まとめ

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

WidgetKit と SwiftData の間のやり取りは App Groups を用いて紐付けを行うことで実現できることがわかりました。
誤っている点やもっと良い書き方があればご指摘いただければ幸いです。

参考

https://medium.com/@rishixcode/swiftdata-with-widgets-in-swiftui-0aab327a35d8

Discussion