【iOS】ウィジェットとCoreDataを連携する入門

2023.02.27

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

ウィジェット上でアプリの情報を確認できるように出来るようにして、よりユーザーに優しいアプリ作りを目指したいということでCoreDataと連携したウィジェットの実装方法について調べることにしました。

ウィジェットとは

よく使うAppの情報をひと目で分かるように表示できる機能で、ホーム画面に追加したり、iOS 16からはロック画面に追加したり出来ます。またウィジットをタップすることでアプリをすぐ利用出来ます。

作ったもの

CoreData-widget-demo

はじめに

今回は、ウィジェットとCoreDataを連携する部分に中心に記載している為、Widget Extension内のメソッドの説明などは省略しております。

【iOS】WidgetKit 入門にWidgetの説明が分かりやすく書いてあったので参考にさせていただきました。

環境

  • Xcode 14.2
  • iOS 16.2

新規プロジェクトを作成

途中から追加することも可能ですが、今回は0からプロジェクトを作成するところから進めていきます。

プロジェクト作成時にUse Core Dataのチェックを入れます。

Widget Extensionを追加

File > New > Targetでテンプレート選択画面が表示される為、Widget Extensionを選択して追加します。

今回は使用しない為、Incluede ~のチェックボックスは両方とも行いません。別の機会に説明できればと思います。

アクティベートしますか?と問われるのでアクティベートを行います。

ウィジットに関するファイルが追加されました。

この時点で、アプリを立ち上げるとウィジットが機能するのを確認出来ます。

App Groupsを追加

App Groupsとは、簡単にいうと共有領域にデータを保存することによって、複数のアプリ間でデータの読み書きが行える機能になります。アプリ側とウィジェット側でデータの参照先が違う為、CoreData連携を行う為に共有領域を活用します。

  • アプリ側ターゲットかウィジェット側ターゲットを選択
    • (最終的に両方共に追加するのでどちらからでも問題ありません)
  • 上部にある+ Capabilityをタップ

追加するCapabilityを選択できる画面が表示されるので、App Groupsをタブルクリックします。

App Groupsが追加されたので画面下部の+ボタンをクリックします。

コンテナを作成するダイアログが表示されるので、任意の名前を入力してOKボタンを押します。今回はgroup.example.coredatawidgetとしました。

アプリ側ターゲットかウィジェット側ターゲットでまだApp Groupsを追加していない方に同様の手順でApp Groupsを追加します。

Widget ExtensionとCoreDataを連携

Target MembershipにWidget Extensionを追加

デフォルトで組み込まれているPersistence.swift.xcdatamodelIdファイルのTarget MembershipにWidget Extensionも含めるためにチェックボックスにチェックを入れます。

CoreDataとApp Groupsを連携

Persistence.swiftのコードをAppGroupsに適用させるために変更を加えます。

struct PersistenceController {
    static let shared = PersistenceController()

    // ~ 省略

    let container: NSPersistentContainer

    init(inMemory: Bool = false) {

        // 参照先をAppGroupsで追加したコンテナに指定
        let appGroupId = "group.example.coredatawidget"
        guard let containerUrl = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupId) else {
            fatalError("Failure to init store URL for AppGroup ID: \(appGroupId)")
        }
        let storeUrl = containerUrl.appendingPathComponent("CoreDataWidgetFirst")

        let description = NSPersistentStoreDescription(url: storeUrl)

        container = NSPersistentContainer(name: "CoreDataWidgetFirstPractice")
        container.persistentStoreDescriptions = [description]
        // ここまでを追加

        if inMemory {
            container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
        }
        container.loadPersistentStores(completionHandler: { (storeDescription, error) in
            if let error = error as NSError? {
                fatalError("Unresolved error \(error), \(error.userInfo)")
            }
        })
        container.viewContext.automaticallyMergesChangesFromParent = true
    }
}

データの参照先をAppGroupsで追加したコンテナに指定することで共有領域を使用できるようにします。

ウィジェット側でPersistenceControllerを参照

ウィジェット側でPersistenceControllerを参照する為に、CoreDataWidget.environmentpersistenceControllerを追加します。

struct CoreDataWidget: Widget {
    let persistenceController = PersistenceController.shared

    let kind: String = "CoreDataWidget"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { _ in
            CoreDataWidgetEntryView()
                // 追加
                .environment(\.managedObjectContext,
                              persistenceController.container.viewContext)
        }
        .configurationDisplayName("My Widget")
        .description("This is an example widget.")
    }
}

WidgetEntryViewでCoreDataの値を参照

Widget Extensionで追加した名前 + EntryViewという構造体がウィジェットのView部分になります。この中の処理をCoreDataの値を参照するように変更しました。

struct CoreDataWidgetEntryView : View {

    @Environment(\.managedObjectContext) private var viewContext

    @FetchRequest(
        sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)],
        animation: .default)
    private var items: FetchedResults<Item>

    var body: some View {

        VStack {
            Rectangle()
                .fill(.blue.gradient)
                .frame(height: 32)
            Divider()

            Spacer()

            ForEach(items) { item in
                Text(item.timestamp ?? Date(), style: .time)
            }

            Spacer()
        }
    }
}

CoreDataから取得したItemをループ処理で描画しているシンプルなものになります。

ウィジェットを更新する処理を追加

ウィジェット側で更新する処理

デフォルトで作成されているProvider構造体の中にあるgetTimelineメソッドでウィジェットの更新タイミングを指定することが出来ます。

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

    // 現在の日付から1時間ごとに5つのエントリで構成されるタイムラインを生成します
    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)
}

デフォルトでは1時間ごとに更新されるようになっています。今回は、タイムラインでの更新は行わない為、特に変更も加えずそのままにしておきます。

アプリ側でウィジェットを更新する処理を追加

今回はアプリ側の特定の処理の際にウィジェットを更新する方法で実装します。

import SwiftUI
import CoreData
// 追加
import WidgetKit

struct ContentView: View {
    @Environment(\.managedObjectContext) private var viewContext

    @FetchRequest(
        sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)],
        animation: .default)
    private var items: FetchedResults<Item>

    var body: some View {
        NavigationView {
            List {
                ForEach(items) { item in
                    NavigationLink {
                        Text("Item at \(item.timestamp!, formatter: itemFormatter)")
                    } label: {
                        Text(item.timestamp!, formatter: itemFormatter)
                    }
                }
                .onDelete(perform: deleteItems)
            }
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    EditButton()
                }
                ToolbarItem {
                    Button(action: addItem) {
                        Label("Add Item", systemImage: "plus")
                    }
                }
            }
            Text("Select an item")
        }
    }

    private func addItem() {
        withAnimation {
            let newItem = Item(context: viewContext)
            newItem.timestamp = Date()

            do {
                try viewContext.save()
                // 追加
                WidgetCenter.shared.reloadAllTimelines()
            } catch {
                let nsError = error as NSError
                fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
            }
        }
    }

    private func deleteItems(offsets: IndexSet) {
        withAnimation {
            offsets.map { items[$0] }.forEach(viewContext.delete)

            do {
                try viewContext.save()
                // 追加
                WidgetCenter.shared.reloadAllTimelines()

            } catch {
                let nsError = error as NSError
                fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
            }
        }
    }
}

private let itemFormatter: DateFormatter = {
    let formatter = DateFormatter()
    formatter.dateStyle = .short
    formatter.timeStyle = .medium
    return formatter
}()

アプリのContentViewファイルにWidgetKitをインポートして、addItemdeleteItemsの処理完了後に、WidgetCenter.shared.reloadAllTimelines()を実行しています。

これでアプリ側で変更加えるとウィジェットに変更が反映されるアプリが出来ました。

reloadAllTimelines

アプリに含まれているすべての構成済みウィジェットのタイムラインを再読み込みします。

おわりに

テンプレートで用意されていたものに少し変更を加えて、CoreDataと連携されたウィジェットを作成出来ました。今回はウィジェットとCoreDataの連携の箇所を中心に説明したので色々と省略したのですが、少しでも誰かの参考になれば嬉しいです。

アプリを開かずとも、ウィジェット上でデータが確認できるとアプリを開く手間が省けて嬉しいですね。

次はロック画面のウィジェットについても調べてみたいと思います。

参考