はじめに
- Xcode 14.2
紹介するサンプルでは以下のModelを使用しています。
SwiftUIにおけるFetchRequest
SwiftUIでCore Dataの機能が使えるFetchedResults
があります。
これを使うことにより、Core Dataの変化を検知してSwiftUIのViewを更新することが可能になっています。
struct ContentView: View {
@Environment(\.managedObjectContext) private var viewContext
@FetchRequest (sortDescriptors: [.init(keyPath: \Item.timestamp, ascending: true)])
private var items: FetchedResults<Item>
var body: some View {
NavigationView {
List {
ForEach(items, id: \.self) { item in
Text(item.amount, format: .currency(code: item.currencyCode!))
}
}
}
}
}
このFetchedResultsは@FetchRequest
を使って定義ができます。
単純なデータ表示であればsortDescriptorsのプロパティを使ってアイテム表示が可能ですが、自分でNSFetchRequestを定義することもできます。
struct ContentView: View {
@Environment(\.managedObjectContext) private var viewContext
private static var itemRequest: NSFetchRequest<Item> {
let fetchRequest: NSFetchRequest<Item> = .init(entityName: "Item")
fetchRequest.sortDescriptors = [.init(keyPath: \Item.timestamp, ascending: true)]
return fetchRequest
}
@FetchRequest(fetchRequest: itemRequest)
private var items: FetchedResults<Item>
var body: some View {
NavigationView {
List {
ForEach(items) { item in
Text(item.timestamp!, formatter: itemFormatter)
}
}
}
}
}
このとき、FetchedResultsで表示するのは、Core DataにEntityとして登録しているItemですが、FetchedResultではNSFetchRequestResultのprotocolに対応したものであればいいので、例えばNSNumberなどもNSFetchRequestResultに対応しているので、こんな感じにすれば件数表示もCore Dataから直接取得して件数表示可能です。
struct CountView: View {
@Environment(\.managedObjectContext) private var viewContext
private static var itemRequest: NSFetchRequest<NSNumber> {
let fetchRequest = NSFetchRequest<NSNumber>(entityName: "Item")
fetchRequest.resultType = .countResultType
fetchRequest.sortDescriptors = [.init(keyPath: \Item.timestamp, ascending: true)]
return fetchRequest
}
@FetchRequest(fetchRequest: itemRequest)
private var items: FetchedResults<NSNumber>
var body: some View {
NavigationView {
List {
ForEach(items, id: \.self) { item in
Text(item.intValue, format: .number)
}
}
}
}
}
クラッシュするFetchRequestのGroup by
同じように、NSFetchRequestではNSDictionaryを使ったGroup byを行った結果を取得することも可能です。なので、以下のような、Group Byを使えるNSFetchRequestを用意しました。これは通貨currencyCodeごとに合計を出す想定のコードです。
struct DictionaryView: View {
@Environment(\.managedObjectContext) private var viewContext
private static var itemRequest: NSFetchRequest<NSDictionary> {
let fetchRequest:NSFetchRequest<NSDictionary> = .init(entityName: "Item")
fetchRequest.resultType = .dictionaryResultType
let amountExpression = NSExpression(forKeyPath: "amount")
let sumExpression = NSExpression(forFunction: "sum:", arguments: [amountExpression])
let sumDescription = NSExpressionDescription()
sumDescription.expression = sumExpression
sumDescription.name = "sumOfAmount"
sumDescription.expressionResultType = .decimalAttributeType
let currencyCodeExpression = NSExpression(forKeyPath: "currencyCode")
let currencyCodeDescription = NSExpressionDescription()
currencyCodeDescription.expression = currencyCodeExpression
currencyCodeDescription.name = "currencyCode"
currencyCodeDescription.expressionResultType = .stringAttributeType
fetchRequest.propertiesToFetch = [currencyCodeDescription, sumDescription]
fetchRequest.propertiesToGroupBy = [currencyCodeDescription]
fetchRequest.sortDescriptors = [NSSortDescriptor.init(key: "timestamp", ascending: true)]
return fetchRequest
}
@FetchRequest(fetchRequest: itemRequest)
private var items: FetchedResults<NSDictionary>
var body: some View {
NavigationView {
List {
ForEach(items, id: \.self) { item in
Text(item.description)
}
}
}
}
}
しかし、このコードを動かしてみると以下のような理由でクラッシュしてしまいます。
2023-01-31 17:14:57.661647+0900 Groups[5801:625811] *** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: 'NSFetchedResultsController does not support both change tracking and fetch request's with NSDictionaryResultType'
クラッシュへの対応
このクラッシュは、SwiftUIだけで見られるものではありません。
例えばUITableViewControllerでCore Dataのデータを表示しようとして、NSFetchedResultsControllerとNSFetchedResultsControllerDelegateを用いてデータの変更をした場合、NSDictionaryResultTypeを使用して表示をしようとすると同じ理由でクラッシュします。
SwiftUIでもおそらくデータの変更対応にNSFetchedResultsControllerを使っているため、同じエラーが起きると思われます。
対処法
Core DataのGroup byを使わずに、NSManagedObjectのモデルを取得して擬似的にGroup byを実装するしかないと思います。
ということで、通貨ごとに合計を出すコードを実装してみます。ついでに+ボタンを押すとユーロが追加されUIが更新されます。
import SwiftUI
import CoreData
struct ContentView: View {
@Environment(\.managedObjectContext) private var viewContext
@FetchRequest (sortDescriptors: [.init(keyPath: \Item.timestamp, ascending: true)])
private var items: FetchedResults<Item>
var body: some View {
NavigationView {
List {
ForEach(groupBy(items).sorted(by: >), id: \.key) { item in
NavigationLink {
Text(item.value, format: .currency(code: item.key))
} label: {
Text(item.value, format: .currency(code: item.key))
}
}
}
.toolbar {
ToolbarItem {
Button(action: addItem) {
Label("Add Item", systemImage: "plus")
}
}
}
}
}
func groupBy(_ items: FetchedResults<Item>) -> [String : Int] {
Dictionary.init(grouping: items) {
$0.currencyCode ?? ""
}
.mapValues { values in
values.reduce(0) { partialResult, item in
partialResult + Int(item.amount)
}
}
}
private func addItem() {
withAnimation {
let newItem = Item(context: viewContext)
newItem.timestamp = Date()
newItem.currencyCode = "EUR"
newItem.amount = 123
do {
try viewContext.save()
} catch {
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
}
}
}