(2023/03/25 更新)
SwiftUIでのCore Dataの使い方を、標準テンプレートを読み解きながら解説します。
環境
この記事の情報は次のバージョンで動作確認しています。
【Swift】5.7.2
【iOS】16.2
【macOS】Ventura 13.2
標準テンプレートの生成
Xcodeで新規プロジェクトを生成します。
プラットフォームはiOSを選択してください。
「Use Core Data」にチェックを入れます。
標準でCore Dataを利用したアプリのテンプレートコードが生成されます。
ファイル拡張子の表示
Xcode13以降、Project navigatorでのファイル拡張子は非表示になっています。
わかりやすいように、メニューバーより下記の設定をして、表示させましょう。
Xcode > Settings > General > File Extensions > Show All
標準テンプレートを動かす
生成されたテンプレートをシミュレータで実行すると、+ボタンとEditボタンが画面上に表示されます。
「+」ボタンでリストの行追加、Editボタンでリストの編集(削除のみ)が可能になります。
一旦アプリを終了してから再度立ち上げても、リストには前の状況が残っており、永続的なデータ保存が実現が確認できます。
次章以降で、標準テンプレートの詳細について解説します。
データモデルの定義
Core Dataで扱うデータモデルは、プロジェクト名.xcdatamodeldファイルで定義されています。
下図の「エンティティ名」がDBのテーブル名、「属性情報」がテーブルのカラム情報に相当します。
標準テンプレートでは、属性項目timestampを1つだけ持つ、 Itemテーブルが定義されています。
このデータモデル定義に従って、Itemクラスが生成されます。
Itemクラスはテーブルの1レコードを格納するクラスで、エンティティの基本クラスである被管理オブジェクト(NSManagedObject)を継承しています。
デフォルトの設定では、ItemクラスのソースコードはXcode上見えません。
リストの表示処理
データベースからデータを取得して、リストに表示する処理は、ContentViewの前半に記述されています。
わかりやすいようにコメントを追加しました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
struct ContentView: View { /// 被管理オブジェクトコンテキスト(ManagedObjectContext)の取得 @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 { /// ナビゲーションバーの左端ににEditボタンを配置 ToolbarItem(placement: .navigationBarTrailing) { EditButton() } /// ナビゲーションバーに+ボタン配置 ToolbarItem { Button(action: addItem) { Label("Add Item", systemImage: "plus") } } } Text("Select an item") // iOSで実行時には表示されない } } |
被管理オブジェクトコンテキスト(ManagedObjectContext)の取得
1 2 3 |
@Environment(\.managedObjectContext) private var viewContext |
被管理オブジェクトコンテキスト(ManagedObjectContext)はデータの生成、保存、取得といったデータベース操作に必要な操作を行う為のオブジェクトです。
このオブジェクトを使ってデータベースアクセスする全ての操作を行います。
ManagedObjectContextはアプリ起動時に生成され、環境変数managedObjectContextに登録されています。
データベースよりデータを取得(Fetch処理)
1 2 3 4 5 6 7 |
/// データベースよりデータを取得 @FetchRequest( sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)], animation: .default) private var items: FetchedResults<Item> |
プロパティラッパーの@FetchRequestを使ってデータベースを検索し、対象データ群をitemsプロパティに格納しています。
引数(sortDescriptors)で検索結果のソート条件(Itemテーブルのtimestamp属性が昇順)を指定、
引数(animation)で取得した検索結果の変更時に使用されるアニメーションタイプを.defaultに指定しています。
取得結果が格納されるitemsは被管理オブジェクト(ManagedObject)のコレクションであるFetchedResults
ManagedObjectはテーブルの1レコードに該当するエンティティを保持するクラスで、ObservableObjectに準拠している為、値の変更がSwiftUIのViewと同期します。
取得したデータをリスト表示
1 2 3 4 5 6 7 8 9 10 11 12 13 |
/// 取得したデータをリスト表示 List { ForEach(items) { item in NavigationLink { Text("Item at \(item.timestamp!, formatter: itemFormatter)") } label: { Text(item.timestamp!, formatter: itemFormatter) } } .onDelete(perform: deleteItems) } |
検索結果itemsの各レコードを表示するTextビューへのリンクをListに表示しています。
item.timestampの出力形式を、formatterで指定していますが、これはSwiftUIで新たに追加されたLocalizedStringKey型で使えるようになった記述方法です。
なお、出力形式を示すitemFormatterは同じファイル内で次のように定義されています。
1 2 3 4 5 6 7 8 |
private let itemFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateStyle = .short formatter.timeStyle = .medium return formatter }() |
List表示処理の最後にある.onDelete()はForEachのModifierです。
コレクション(配列)から要素を削除する操作が行われた際に呼び出す処理(deleteItems)を指定しています。
ナビゲーションバーの設定
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
/// ナビゲーションバーの設定 .toolbar { /// ナビゲーションバーの左端ににEditボタンを配置 ToolbarItem(placement: .navigationBarTrailing) { EditButton() } /// ナビゲーションバーに+ボタン配置 ToolbarItem { Button(action: addItem) { Label("Add Item", systemImage: "plus") } } } Text("Select an item") // iOSで実行時には表示されない |
ナビゲーションバーの左端にEditボタン、その横に「+」ボタンを設置しています。
「+」ボタンが押された場合は、次章の項目追加処理(addItem関数)が呼ばれます。
最後のTextビューはiOSでは表示されませんが、NavigationViewをNavigationStackに変更するとフッターに表示されます。
項目追加処理
addItem()ファンクションで「+」ボタンを押された時に呼ばれる項目追加処理を行っています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
private func addItem() { withAnimation { /// 新規レコードの作成 let newItem = Item(context: viewContext) newItem.timestamp = Date() /// データベースの保存 do { try viewContext.save() } catch { // Replace this implementation with code to handle the error appropriately. // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. let nsError = error as NSError fatalError("Unresolved error \(nsError), \(nsError.userInfo)") } } } |
新規レコードの作成
1 2 3 4 |
let newItem = Item(context: viewContext) newItem.timestamp = Date() |
ItemクラスのインスタンスであるnewItemを生成し、属性timestampに現在時刻(Date())を設定しています。
Itemクラスのイニシャライザには、引数としてデータベースを操作するManagedObjectContextを渡す必要があります。
データベースの保存
1 2 3 |
try viewContext.save() |
ManagedObjectContextのsave()メソッドで、データベースを保存(コミット)します。
このメソッドはエラーを返す可能性がある為、tryを使ったエラーキャッチが必要です。
アニメーションの指定
1 2 3 |
withAnimation { |
新規レコード追加表示時にアニメーションを適用しています。
@FetchRequestで取得したデータ群に対して、既にデフォルトのアニメーションタイプが指定されているので、この記述は蛇足のように思います。
項目削除処理
行の削除が要求された場合は、deleteItems()ファンクションが呼ばれます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
private func deleteItems(offsets: IndexSet) { withAnimation { /// レコードの削除 offsets.map { items[$0] }.forEach(viewContext.delete) /// データベースの保存 do { try viewContext.save() } catch { // Replace this implementation with code to handle the error appropriately. // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. let nsError = error as NSError fatalError("Unresolved error \(nsError), \(nsError.userInfo)") } } } |
レコードの削除
1 2 3 |
offsets.map { items[$0] }.forEach(viewContext.delete) |
記述が複雑で、ぱっと見わかりずいらいですが、同様の処理は次のように書き直せます。
1 2 3 4 5 |
for index in offsets { viewContext.delete(items[index]) } |
offsetsには削除対象の要素番号のコレクションが渡ってきますので、各要素番号に対応したエンティティをループで回して削除します。
削除には対象のエンティティを引数に、ManagedObjectContextのdelete()メソッドを呼び出します。
項目追加処理と同様に、データベースを保存(コミット)が必要です。
アプリ起動時の処理
新規プロジェクト作成時に生成される「プロジェクト名App.swift」ファイル内に、アプリ起動時の処理が記述されています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
import SwiftUI @main struct CoreDataTemplateApp: App { /// 永続コンテナのコントローラー生成 let persistenceController = PersistenceController.shared var body: some Scene { WindowGroup { ContentView() /// ManagedObjectContextを環境変数に追加 .environment(\.managedObjectContext, persistenceController.container.viewContext) } } } |
永続コンテナのコントローラー生成
1 2 3 |
let persistenceController = PersistenceController.shared |
Core Dataに必要なオブジェクトの作成と管理を行う永続コンテナ(PersistentContainer)のコントローラーを生成します。
コントローラーの詳細については後述します。
ManagedObjectContextを環境変数に追加
1 2 3 |
.environment(\.managedObjectContext, persistenceController.container.viewContext) |
データベース操作に使うManagedObjectContextを環境変数managedObjectContextに設定し、アプリケーションの各Viewで使用可能にします。
永続コンテナコントローラー(PersistenceController)
Core Dataに必要なオブジェクトの作成と管理を行う永続コンテナ(PersistentContainer)のコントローラーで、新規プロジェクト作成時にUse Core Dataチェックを入れると生成されるPersistent.swiftファイルに定義されています。
インスタンスの取得方法には次の2通りあります。
PersistenceController.shared
通常版の永続コンテナコントローラーを返します。
PersistenceController.preview
preview用の永続コンテナコントローラーを返します。
通常版との違いは次の2点です。
- preview用に初期データが設定される
- DBが実ファイルでなくメモリ上に構築される(コミットしても永続化されない)
プレビュー(preview)処理
1 2 3 4 5 6 7 8 |
struct ContentView_Previews: PreviewProvider { static var previews: some View { /// ManagedObjectContextを環境変数に設定 ContentView().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext) } } |
ContentViewを表示するには、環境変数managedObjectContextが設定されている必要がある為、.environmentモディファイアを使って設定しています。
このとき使用しているPersistenceController.previewではプレビュー用のDB初期値が設定されます。
DB初期値は、Persistent.swiftの次の箇所で設定されていますので、ここを書き換えるとプレビュー時の初期値を変更可能です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
static var preview: PersistenceController = { let result = PersistenceController(inMemory: true) let viewContext = result.container.viewContext /// プレビュー用初期値の設定 for _ in 0..<10 { let newItem = Item(context: viewContext) newItem.timestamp = Date() } do { try viewContext.save() } catch { let nsError = error as NSError fatalError("Unresolved error \(nsError), \(nsError.userInfo)") } return result }() |