SwiftUI Core Data Relationship Not Updating View

I've run into a problem with the following setup:

I have a Core Data database with parent objects with a relationship to many child objects. I have a main view using SwiftUI FetchedResults that displays the parent objects in a list and properties about the child objects. I have a second view that lets you change properties of the child objects.

The parent object has the property: name (string). The child object has: name (string) and star (bool). On the main view I display a list of parent objects and how many child objects are starred. On the second view I update if a child object is starred. Upon updating the property on the second view and saving, the first view does not update until you relaunch the app.

Here is the main view:

struct ContentView: View {
    
    @Environment(\.managedObjectContext) private var viewContext
    
    @FetchRequest(sortDescriptors: [SortDescriptor(\.name)])
    private var parents: FetchedResults<Parent>
    
    @State private var editingParent: Parent?
    
    var body: some View {
        
        List {
            ForEach(parents) { parent in
                VStack(alignment: .leading) {
                    Text(parent.name ?? "No Name")
                    Text("\(parent.children!.count) children")
                    Text("\(getNumberOfStar(parent: parent)) Stared")
                }
                .onTapGesture {
                    editingParent = parent
                }
                                
            }
        }
        
        .sheet(item: $editingParent) { parent in
            ChildrenView(parent: parent)
        }
        
        Button {
            PersistenceController.shared.saveParent(name: "New Parent")
        } label: {
            Text("Add Parent")
        }.padding()

        Button {
            parents.forEach { parent in
                PersistenceController.shared.delete(parent: parent)
            }
        } label: {
            Text("Delete All")
        }.padding()
        
    }
    
    //this function does not get called when the database changes
    func getNumberOfStar(parent: Parent) -> Int {
        let children = parent.children!.allObjects as! [Child]
        let starred = children.filter({$0.star == true})
        return starred.count
    }
    
    
}

This is the child view:

    
    
    @FetchRequest var children: FetchedResults<Child>
    
    var parent: Parent
    
    init(parent: Parent) {
        self.parent = parent
        _children = FetchRequest<Child>(sortDescriptors: [], predicate: NSPredicate(format: "parent = %@", parent))
    }
    
    
    var body: some View {
        List {
            ForEach(children) { child in
                HStack {
                    Text(child.name ?? "No Name")
                    Text(child.star ? "⭐️" : "")
                    Spacer()
                    Button {
                        PersistenceController.shared.updateStar(child: child)
                    } label: {
                        Text("Toggle Star")
                    }
                }
            }
        }
        Button {
            PersistenceController.shared.saveChild(name: "New Child", parent: parent)
        } label: {
            Text("Add Child")
        }
    }
    
    
    
}

This is the Persistence Controller:

    
    static let shared = PersistenceController()
    
    let container: NSPersistentContainer
    
    init(inMemory: Bool = false) {
        container = NSPersistentContainer(name: "Model")
        if inMemory {
            container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
        }
        container.loadPersistentStores(completionHandler: { (storeDescription, error) in
            if let error = error as NSError? {
                // 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.
                
                /*
                 Typical reasons for an error here include:
                 * The parent directory does not exist, cannot be created, or disallows writing.
                 * The persistent store is not accessible, due to permissions or data protection when the device is locked.
                 * The device is out of space.
                 * The store could not be migrated to the current model version.
                 Check the error message to determine what the actual problem was.
                 */
                fatalError("Unresolved error \(error), \(error.userInfo)")
            }
        })
        container.viewContext.automaticallyMergesChangesFromParent = true
    }
    
    func save() {
        let viewContext = container.viewContext
        do {
            try viewContext.save()
        } catch {
            /**
             Real-world apps should consider better handling the error in a way that fits their UI.
             */
            let nsError = error as NSError
            fatalError("Failed to save Core Data changes: \(nsError), \(nsError.userInfo)")
        }
                
    }
    
    
    func saveParent(name: String) {
        let newParent = Parent(context: container.viewContext)
        newParent.name = name
        save()
    }
    
    func saveChild(name: String, parent: Parent) {
        let newChild = Child(context: container.viewContext)
        newChild.name = name
        newChild.parent = parent
        newChild.star = false 
        save()
    }
    
    func updateStar(child: Child) {
        child.star.toggle()
        save()
    }
    
    func delete(parent: Parent) {
        let viewContext = container.viewContext
        viewContext.delete(parent)
        save()
    }
    
    
}

Finally here is the App struct:

struct Core_Data_Child_Save_ExampleApp: App {
    
    let persistenceController = PersistenceController.shared

    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(\.managedObjectContext, persistenceController.container.viewContext)

        }
    }
}
Answered by DTS Engineer in 816769022

Oh, FetchRequest, as well as NSFetchedResultsController, doesn’t monitor the change on a relationship’s attribute. If your change on a child’s attribute doesn't trigger any change on the parent object, the fetched result set won't change, and hence no SwiftUI update will be triggered.

To handle this kind of issues, I typically consider the following options:

  1. Trigger a SwiftUI update to refresh the parent view, which re-fetches the result set. You can probably do so by passing a state of the parent view as a binding to the child view, and updating the binding as needed.

  2. Add a redundant field in the parent entity, starredCount, for example, to store the count of starred children. Obviously, this doesn't follow the database design paradigm and brings you extra work to maintain the field when updating a child.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

Did you try ObservedObject, as shown below?

@ObservedObject var parent: Parent

NSManagedObject doesn't conform Observable. In your case, when you update the parent object, SwiftUI doesn't detect the change. NSManagedObject conforms ObservableObject though, and so annotating a managed object with ObservedObject should help invalidate the relavant views when the object is changed.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

Hello, thanks for the reply. I tried that and it still doesn't update the main screen with the list of parent objects. It appears that

@FetchRequest(sortDescriptors: [SortDescriptor(\.name)])
 private var parents: FetchedResults<Parent>

will not update if a value of a property changes from a child object from a to many relationship. Is there a way around that?

Accepted Answer

Oh, FetchRequest, as well as NSFetchedResultsController, doesn’t monitor the change on a relationship’s attribute. If your change on a child’s attribute doesn't trigger any change on the parent object, the fetched result set won't change, and hence no SwiftUI update will be triggered.

To handle this kind of issues, I typically consider the following options:

  1. Trigger a SwiftUI update to refresh the parent view, which re-fetches the result set. You can probably do so by passing a state of the parent view as a binding to the child view, and updating the binding as needed.

  2. Add a redundant field in the parent entity, starredCount, for example, to store the count of starred children. Obviously, this doesn't follow the database design paradigm and brings you extra work to maintain the field when updating a child.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

Thanks! the state/binding trick worked.

SwiftUI Core Data Relationship Not Updating View
 
 
Q