How to update @FetchRequest, when a related Entity changes in SwiftUI?
Solution 1
I also struggled with this and found a very nice and clean solution:
You have to wrap the row in a separate view and use @ObservedObject in that row view on the entity.
Here's my code:
WineList:
struct WineList: View {
@FetchRequest(entity: Wine.entity(), sortDescriptors: [
NSSortDescriptor(keyPath: \Wine.name, ascending: true)
]
) var wines: FetchedResults<Wine>
var body: some View {
List(wines, id: \.id) { wine in
NavigationLink(destination: WineDetail(wine: wine)) {
WineRow(wine: wine)
}
}
.navigationBarTitle("Wines")
}
}
WineRow:
struct WineRow: View {
@ObservedObject var wine: Wine // !! @ObserveObject is the key!!!
var body: some View {
HStack {
Text(wine.name ?? "")
Spacer()
}
}
}
Solution 2
You need a Publisher which would generate event about changes in context and some state variable in primary view to force view rebuild on receive event from that publisher.
Important: state variable must be used in view builder code, otherwise rendering engine would not know that something changed.
Here is simple modification of affected part of your code, that gives behaviour that you need.
@State private var refreshing = false
private var didSave = NotificationCenter.default.publisher(for: .NSManagedObjectContextDidSave)
var body: some View {
List {
ForEach(fetchedResults) { primary in
NavigationLink(destination: SecondaryView(primary: primary)) {
VStack(alignment: .leading) {
// below use of .refreshing is just as demo,
// it can be use for anything
Text("\(primary.primaryName ?? "nil")" + (self.refreshing ? "" : ""))
Text("\(primary.secondary?.secondaryName ?? "nil")").font(.footnote).foregroundColor(.secondary)
}
}
// here is the listener for published context event
.onReceive(self.didSave) { _ in
self.refreshing.toggle()
}
}
}
.navigationBarTitle("Primary List")
.navigationBarItems(trailing:
Button(action: {self.addNewPrimary()} ) {
Image(systemName: "plus")
}
)
}
Solution 3
An alternative method: using a Publisher and List.id():
struct ContentView: View {
/*
@FetchRequest...
*/
private var didSave = NotificationCenter.default.publisher(for: .NSManagedObjectContextDidSave) //the publisher
@State private var refreshID = UUID()
var body: some View {
List {
...
}
.id(refreshID)
.onReceive(self.didSave) { _ in //the listener
self.refreshID = UUID()
print("generated a new UUID")
}
}
}
Every time you call save() of NSManagedObjects in a context, it genertates a new UUID for the List view, and it forces the List view to refresh.
Solution 4
To fix that you have to add @ObservedObject
to var primary: Primary
in SecondaryView
to work List
properly. Primary
belong to NSManagedObject
class, which already conforms to @ObservableObject
protocol. This way the changes in instances of Primary
are observed.
import SwiftUI
extension Primary: Identifiable {}
// Primary View
struct PrimaryListView: View {
@Environment(\.managedObjectContext) var context
@FetchRequest(
entity: Primary.entity(),
sortDescriptors: [NSSortDescriptor(key: "primaryName", ascending: true)]
)
var fetchedResults: FetchedResults<Primary>
var body: some View {
List {
ForEach(fetchedResults) { primary in
NavigationLink(destination: SecondaryView(primary: primary)) {
VStack(alignment: .leading) {
Text("\(primary.primaryName ?? "nil")")
Text("\(primary.secondary?.secondaryName ?? "nil")").font(.footnote).foregroundColor(.secondary)
}
}
}
}
.navigationBarTitle("Primary List")
.navigationBarItems(trailing:
Button(action: {self.addNewPrimary()} ) {
Image(systemName: "plus")
}
)
}
private func addNewPrimary() {
let newPrimary = Primary(context: context)
newPrimary.primaryName = "Primary created at \(Date())"
let newSecondary = Secondary(context: context)
newSecondary.secondaryName = "Secondary built at \(Date())"
newPrimary.secondary = newSecondary
try? context.save()
}
}
struct PrimaryListView_Previews: PreviewProvider {
static var previews: some View {
let context = (UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext
return NavigationView {
PrimaryListView().environment(\.managedObjectContext, context)
}
}
}
// Detail View
struct SecondaryView: View {
@Environment(\.presentationMode) var presentationMode
@ObservedObject var primary: Primary
@State private var newSecondaryName = ""
var body: some View {
VStack {
TextField("Secondary name:", text: $newSecondaryName)
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding()
.onAppear {self.newSecondaryName = self.primary.secondary?.secondaryName ?? "no name"}
Button(action: {self.saveChanges()}) {
Text("Save")
}
.padding()
}
}
private func saveChanges() {
primary.secondary?.secondaryName = newSecondaryName
try? primary.managedObjectContext?.save()
presentationMode.wrappedValue.dismiss()
}
}
Solution 5
I tried to touch the primary object in the detail view like this:
// TODO: ❌ workaround to trigger update on primary @FetchRequest
if let primary = secondary.primary {
secondary.managedObjectContext?.refresh(primary, mergeChanges: true)
}
Then the primary list will update. But the detail view has to know about the parent object. This will work, but this is probably not the SwiftUI or Combine way...
Edit:
Based on the above workaround, I modified my project with a global save(managedObject:) function. This will touch all related Entities, thus updating all relevant @FetchRequest's.
import SwiftUI
import CoreData
extension Primary: Identifiable {}
// MARK: - Primary View
struct PrimaryListView: View {
@Environment(\.managedObjectContext) var context
@FetchRequest(
sortDescriptors: [
NSSortDescriptor(keyPath: \Primary.primaryName, ascending: true)]
)
var fetchedResults: FetchedResults<Primary>
var body: some View {
print("body PrimaryListView"); return
List {
ForEach(fetchedResults) { primary in
NavigationLink(destination: SecondaryView(secondary: primary.secondary!)) {
VStack(alignment: .leading) {
Text("\(primary.primaryName ?? "nil")")
Text("\(primary.secondary?.secondaryName ?? "nil")")
.font(.footnote).foregroundColor(.secondary)
}
}
}
}
.navigationBarTitle("Primary List")
.navigationBarItems(trailing:
Button(action: {self.addNewPrimary()} ) {
Image(systemName: "plus")
}
)
}
private func addNewPrimary() {
let newPrimary = Primary(context: context)
newPrimary.primaryName = "Primary created at \(Date())"
let newSecondary = Secondary(context: context)
newSecondary.secondaryName = "Secondary built at \(Date())"
newPrimary.secondary = newSecondary
try? context.save()
}
}
struct PrimaryListView_Previews: PreviewProvider {
static var previews: some View {
let context = (UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext
return NavigationView {
PrimaryListView().environment(\.managedObjectContext, context)
}
}
}
// MARK: - Detail View
struct SecondaryView: View {
@Environment(\.presentationMode) var presentationMode
var secondary: Secondary
@State private var newSecondaryName = ""
var body: some View {
print("SecondaryView: \(secondary.secondaryName ?? "")"); return
VStack {
TextField("Secondary name:", text: $newSecondaryName)
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding()
.onAppear {self.newSecondaryName = self.secondary.secondaryName ?? "no name"}
Button(action: {self.saveChanges()}) {
Text("Save")
}
.padding()
}
}
private func saveChanges() {
secondary.secondaryName = newSecondaryName
// save Secondary and touch Primary
(UIApplication.shared.delegate as! AppDelegate).save(managedObject: secondary)
presentationMode.wrappedValue.dismiss()
}
}
extension AppDelegate {
/// save and touch related objects
func save(managedObject: NSManagedObject) {
let context = persistentContainer.viewContext
// if this object has an impact on related objects, touch these related objects
if let secondary = managedObject as? Secondary,
let primary = secondary.primary {
context.refresh(primary, mergeChanges: true)
print("Primary touched: \(primary.primaryName ?? "no name")")
}
saveContext()
}
}
Related videos on Youtube
Björn B.
Updated on June 04, 2022Comments
-
Björn B. almost 2 years
In a SwiftUI
View
i have aList
based on@FetchRequest
showing data of aPrimary
entity and the via relationship connectedSecondary
entity. TheView
and itsList
is updated correctly, when I add a newPrimary
entity with a new related secondary entity.The problem is, when I update the connected
Secondary
item in a detail view, the database gets updated, but the changes are not reflected in thePrimary
List. Obviously, the@FetchRequest
does not get triggered by the changes in another View.When I add a new item in the primary view thereafter, the previously changed item gets finally updated.
As a workaround, i additionally update an attribute of the
Primary
entity in the detail view and the changes propagate correctly to thePrimary
View.My question is: How can I force an update on all related
@FetchRequests
in SwiftUI Core Data? Especially, when I have no direct access to the related entities/@Fetchrequests
?import SwiftUI extension Primary: Identifiable {} // Primary View struct PrimaryListView: View { @Environment(\.managedObjectContext) var context @FetchRequest( entity: Primary.entity(), sortDescriptors: [NSSortDescriptor(key: "primaryName", ascending: true)] ) var fetchedResults: FetchedResults<Primary> var body: some View { List { ForEach(fetchedResults) { primary in NavigationLink(destination: SecondaryView(primary: primary)) { VStack(alignment: .leading) { Text("\(primary.primaryName ?? "nil")") Text("\(primary.secondary?.secondaryName ?? "nil")").font(.footnote).foregroundColor(.secondary) } } } } .navigationBarTitle("Primary List") .navigationBarItems(trailing: Button(action: {self.addNewPrimary()} ) { Image(systemName: "plus") } ) } private func addNewPrimary() { let newPrimary = Primary(context: context) newPrimary.primaryName = "Primary created at \(Date())" let newSecondary = Secondary(context: context) newSecondary.secondaryName = "Secondary built at \(Date())" newPrimary.secondary = newSecondary try? context.save() } } struct PrimaryListView_Previews: PreviewProvider { static var previews: some View { let context = (UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext return NavigationView { PrimaryListView().environment(\.managedObjectContext, context) } } } // Detail View struct SecondaryView: View { @Environment(\.presentationMode) var presentationMode var primary: Primary @State private var newSecondaryName = "" var body: some View { VStack { TextField("Secondary name:", text: $newSecondaryName) .textFieldStyle(RoundedBorderTextFieldStyle()) .padding() .onAppear {self.newSecondaryName = self.primary.secondary?.secondaryName ?? "no name"} Button(action: {self.saveChanges()}) { Text("Save") } .padding() } } private func saveChanges() { primary.secondary?.secondaryName = newSecondaryName // TODO: ❌ workaround to trigger update on primary @FetchRequest primary.managedObjectContext.refresh(primary, mergeChanges: true) // primary.primaryName = primary.primaryName try? primary.managedObjectContext?.save() presentationMode.wrappedValue.dismiss() } }
-
Darrell Root over 4 yearsHere's hoping Apple improves the Core Data <-> SwiftUI integration in the future. Awarding the bounty to the best answer provided. Thanks Asperi.
-
Björn B. over 4 yearsThank You for Your answer! But @FetchRequest should react to changes in the database. With Your solution, the View will be updated with every save on the database, regardless of the items involved. My question was how to get @FetchRequest to react on changes involving database relations. Your solution needs a second subscriber (the NotificationCenter) in parallel to the @FetchRequest. Also one has to use an additional fake trigger ` + (self.refreshing ? "" : "")`. Maybe a @Fetchrequest is not a suitable solution itself?
-
Asperi over 4 yearsYes, you're right, but the fetch request as it is created in example is not affected by the changes that are made lately, that is why it is not updated/refetched. May be there is a reason to consider different fetch request criteria, but that is different question.
-
Björn B. over 4 years@Asperi I accept Your answer. As You stated, the problem lies somehow with the rendering engine to recognise any changes. Using a reference to a changed Object does not suffice. A changed variable must be used in a View. In any portion of the body. Even used on a background on the List will work. I use a
RefreshView(toggle: Bool)
with a single EmptyView in its body. UsingList {...}.background(RefreshView(toggle: self.refreshing))
will work. -
Asperi about 4 yearsI've found better way to force List refresh/refetch, it is provided in SwiftUI: List does not update automatically after deleting all Core Data Entity entries. Just in case.
-
davidev about 4 years@Asperi What a brilliant answer sir! Awesome job... thank you so much for your answers to SwiftUI :) You help me a lot
-
Steven Wilber over 3 yearsThis worked perfectly for me. Thanks. It's also obvious in hindsight which is nice as it means that it fits the SwiftUI way of things. Thanks.
-
Peter Schorn over 3 yearsThis should be the accepted answer. It's much simpler and aligns more with the SwiftUI philosophy.
-
Niall Kehoe over 3 yearsWhat an elegant solution!
-
mikemike396 over 3 yearsKey is the "@ObserveObject" You rocked! Works perfect!
-
thisIsTheFoxe over 3 yearsOMFG!! How? How is that not the accepted answer? This is soo clean, easy, and works perfectly.... Thanks, marc <3
-
MatzeLoCal over 3 years@g-marc answer is the correkt one link
-
alex about 3 yearsAwesome, thanks for this one. Usually people leave out too much from their example code, but yours had enough to help me understand how it fits into my code!
-
Nicolas over 2 yearsIf am using exactly this but if WineRow changes its height due to the change, the list seems not to update the cell correctly. :(
-
Maddocks about 2 yearsthis worked very easily, however had to change
private var
tolet
cause you would have to implant custom init for struct.