ウィジェット上でアプリの情報を確認できるように出来るようにして、よりユーザーに優しいアプリ作りを目指したいということでCoreDataと連携したウィジェットの実装方法について調べることにしました。
ウィジェットとは
よく使うAppの情報をひと目で分かるように表示できる機能で、ホーム画面に追加したり、iOS 16からはロック画面に追加したり出来ます。またウィジットをタップすることでアプリをすぐ利用出来ます。
作ったもの
はじめに
今回は、ウィジェットと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
の.environment
にpersistenceController
を追加します。
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
をインポートして、addItem
とdeleteItems
の処理完了後に、WidgetCenter.shared.reloadAllTimelines()
を実行しています。
これでアプリ側で変更加えるとウィジェットに変更が反映されるアプリが出来ました。
reloadAllTimelines
アプリに含まれているすべての構成済みウィジェットのタイムラインを再読み込みします。
おわりに
テンプレートで用意されていたものに少し変更を加えて、CoreDataと連携されたウィジェットを作成出来ました。今回はウィジェットとCoreDataの連携の箇所を中心に説明したので色々と省略したのですが、少しでも誰かの参考になれば嬉しいです。
アプリを開かずとも、ウィジェット上でデータが確認できるとアプリを開く手間が省けて嬉しいですね。
次はロック画面のウィジェットについても調べてみたいと思います。