8

A Core Data model with entity Node having name, createdAt, to-many relationship children and to-one relationship parent (both optional). Using CodeGen Class Definition.

Using a @FetchRequest with a predicate of parent == nil, it's possible to grab the root nodes and subsequently walk the tree using the relationships.

Root nodes CRUD refreshes the view fine, but any modifications to child nodes don't display until restart although changes are saved in Core Data.

Simplest possible example in the code below illustrates the problem with child node deletion. The deletion works in Core Data but the view does not refresh if the deletion is on a child. The view refresh works fine if on a root node.

I'm new to Swift, so my apologies if this is a rather elementary question, but how can the view be refreshed upon changes to the child nodes?

import SwiftUI
import CoreData

extension Node {

    class func count() -> Int {
        let context = (UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext
        let fetchRequest: NSFetchRequest<Node> = Node.fetchRequest()

        do {
            let count = try context.count(for: fetchRequest)
            print("found nodes: \(count)")
            return count
        } catch let error as NSError {
            fatalError("Unresolved error \(error), \(error.userInfo)")
        }
    }
}

struct ContentView: View {

    @Environment(\.managedObjectContext) var managedObjectContext

    @FetchRequest(entity: Node.entity(), sortDescriptors: [NSSortDescriptor(keyPath: \Node.createdAt, ascending: true)], predicate: NSPredicate(format: "parent == nil"))
    var nodes: FetchedResults<Node>

    var body: some View {

        NavigationView {
            List {
                NodeWalkerView(nodes: Array(nodes.map { $0 as Node })  )
            }
            .navigationBarItems(trailing: EditButton())
        }
        .onAppear(perform: { self.loadData() } )

    }
    func loadData() {
        if Node.count() == 0 {
            for i in 0...3 {
                let node = Node(context: self.managedObjectContext)
                node.name = "Node \(i)"
                for j in 0...2 {
                    let child = Node(context: self.managedObjectContext)
                    child.name = "Child \(i).\(j)"
                    node.addToChildren(child)
                    for k in 0...2 {
                        let subchild = Node(context: self.managedObjectContext)
                        subchild.name = "Subchild \(i).\(j).\(k)"
                        child.addToChildren(subchild)
                    }
                }
            }
            do {
                try self.managedObjectContext.save()
            } catch {
                print(error)
            }
        }
    }
}

struct NodeWalkerView: View {

    @Environment(\.managedObjectContext) var managedObjectContext

    var nodes: [Node]

    var body: some View {

        ForEach( self.nodes, id: \.self ) { node in
            NodeListWalkerCellView(node: node)
        }
        .onDelete { (indexSet) in
            let nodeToDelete = self.nodes[indexSet.first!]
            self.managedObjectContext.delete(nodeToDelete)
            do {
                try self.managedObjectContext.save()
            } catch {
                print(error)
            }
        }
    }
}

struct NodeListWalkerCellView: View {

    @ObservedObject var node: Node

    var body: some View {

        Section {
            Text("\(node.name ?? "")")
            if node.children!.count > 0 {
                NodeWalkerView(nodes: node.children?.allObjects as! [Node] )
                .padding(.leading, 30)
            }
        }
    }
}

EDIT:

A trivial but unsatisfying solution is to make NodeListWakerCellView retrieve the children using another @FetchRequest but this feels wrong since the object is already available. Why run another query? But perhaps this is currently the only way to attach the publishing features?

I am wondering if there's another way to use a Combine publisher directly to the children, perhaps within the .map?

struct NodeListWalkerCellView: View {

    @ObservedObject var node: Node

    @FetchRequest var children: FetchedResults<Node>

    init( node: Node ) {
        self.node = node
        self._children = FetchRequest(
            entity: Node.entity(),
            sortDescriptors: [NSSortDescriptor(keyPath: \Node.createdAt, ascending: false)],
            predicate: NSPredicate(format: "%K == %@", #keyPath(Node.parent), node)
        )
    }

    var body: some View {

        Section {
            Text("\(node.name ?? "")")
            if node.children!.count > 0 {

                NodeWalkerView(nodes: children.map({ $0 as Node }) )

                .padding(.leading, 30)
            }
        }
    }
}
user192742
  • 81
  • 1
  • 3
  • 1
    I would say this is duplicate of [How to update @FetchRequest, when a related Entity changes in SwiftUI?](https://stackoverflow.com/questions/58643094/how-to-update-fetchrequest-when-a-related-entity-changes-in-swiftu) – Asperi Nov 14 '19 at 09:47
  • 1
    @Asperi I reviewed that question prior to posting and unless I'm missunderstanding something, this question differs because the tree walker `map` step passes an array between views. I realize that `same question` isn't the same as `same solution`, but FWIW I wasn't able to get the proposed solution working and the solution itself feels like a compromise, especially in how the `context` is passed around in a non-standard way (from what I can tell). But at the end of the day, I just want a workable solution so I'm open to any suggestions. – user192742 Nov 14 '19 at 11:13
  • I've posted a solution that should work for you to a similar question here: https://stackoverflow.com/a/65309334/7965564 – Brian M Dec 15 '20 at 17:04
  • @user192742 Did you manage to find a solution when `parent == nil`? Or any workaround that used? – user1046037 Feb 01 '22 at 12:44
  • @user1046037, it's long to replicate the problem behavior in test project, can you provide access to your project? The key principle is that every SwiftUI view (represented CoreData object) should be joined with some observable entity (either fetch request wrapper or observed object wrapper). In the code above this rule is broken in `NodeWalkerView`, which does not observe anything, so there is an issue. – Asperi Feb 01 '22 at 14:23
  • @Asperi I am able to observe the parent entity in most cases as an `@ObservedObject`, but the problem comes when I have a list which has a predicate where `parent == nil`, so I have nothing to observe in this case. That is where I am stuck – user1046037 Feb 01 '22 at 14:33
  • 1
    if all your UI info comes from the `node` FetchRequests then it should be enough to call `node.ObjectWillChange.send()`wherever you modify the children. – ChrisR Feb 01 '22 at 19:04
  • 1
    This code works fine on Version 13.2.1 (13C100) and iOS 15.2 – lorem ipsum Feb 01 '22 at 20:51
  • @ChrisR This would make sense, however I have a lot of model code which modifies the relationship, so am just wondering if there is a better way to do it instead of calling it each time explicitly – user1046037 Feb 02 '22 at 00:31
  • @loremipsum Yes you are right!!! Strangely when `parent == nil` there is no need to observe anything additional and things automatically update. Based on the testing, when you have a list showing the items of a specific parent, observe the parent (using `@ObservedObject`), if you are showing list of items with no parent (`parent == nil`) then there is no need to observe anything additional. – user1046037 Feb 02 '22 at 01:02
  • So what specifically do you need help with? What needs to change to replicate/solve the issue? – lorem ipsum Feb 02 '22 at 02:02
  • @loremipsum I think I better open a new question to add more clarity on the exact issue I am facing. Now I have managed to identify `@FetchRequest` seems not be updating when I have a predicate with a boolean condition AND parent filter – user1046037 Feb 02 '22 at 04:56
  • Have a look at [ObservedObjectCollection](https://stackoverflow.com/questions/57459727/why-an-observedobject-array-is-not-updated-in-my-swiftui-application/64524756#64524756), this is useful if you need to observe changes in related objects without presenting subviews that observe them. The motivating example in my case a view (observing the *relating* object) that presented a value derived from the *related* objects, without observing the related objects themselves. – Luke Howard Oct 26 '20 at 01:25

1 Answers1

3

You can easily observe all the changes by observing the NSManagedObjectContextObjectsDidChange Notification and refreshing the View.

In the code below you can replicate the issue as follows.

  1. Create a Node entity with the following attributes and relationships enter image description here

2. Paste the code below into the project

  1. Run the NodeContentView on simulator

  2. Select one of the nodes in the first screen

  3. Edit the node's name

  4. Click on the "Back" button

  5. Notice the name of the selected variable didn't change.

How to "solve"

  1. Uncomment //NotificationCenter.default.addObserver(self, selector: #selector(refreshView), name: Notification.Name.NSManagedObjectContextObjectsDidChange, object: nil) that is located in the init of CoreDataPersistence

  2. Follow steps 3-6

  3. Notice that the node name was updated this time.

import SwiftUI
import CoreData

extension Node {
    public override func awakeFromInsert() {
        super.awakeFromInsert()
        self.createdAt = Date()
    }
}
///Notice the superclass the code is below
class NodePersistence: CoreDataPersistence{
    func loadSampleData() {
        if NodeCount() == 0 {
            for i in 0...3 {
                let node: Node = create()
                node.name = "Node \(i)"
                for j in 0...2 {
                    let child: Node = create()
                    child.name = "Child \(i).\(j)"
                    node.addToChildren(child)
                    for k in 0...2 {
                        let subchild: Node = create()
                        subchild.name = "Subchild \(i).\(j).\(k)"
                        child.addToChildren(subchild)
                    }
                }
            }
            save()
        }
    }
    func NodeCount() -> Int {
        let fetchRequest: NSFetchRequest<Node> = Node.fetchRequest()
        
        do {
            let count = try context.count(for: fetchRequest)
            print("found nodes: \(count)")
            return count
        } catch let error as NSError {
            fatalError("Unresolved error \(error), \(error.userInfo)")
        }
    }
}

struct NodeContentView: View {
    //Create the class that sets the appropriate context
    @StateObject var nodePers: NodePersistence = .init()
    
    var body: some View{
        NodeListView()
        //Pass the modified context
            .environment(\.managedObjectContext, nodePers.context)
            .environmentObject(nodePers)
    }
}

struct NodeListView: View {
    @EnvironmentObject var nodePers: NodePersistence
    @FetchRequest(sortDescriptors: [NSSortDescriptor(keyPath: \Node.createdAt, ascending: true)], predicate: NSPredicate(format: "parent == nil"))
    var nodes: FetchedResults<Node>
    
    var body: some View {
        NavigationView {
            List {
                NodeWalkerView(nodes: Array(nodes))
            }
            .navigationBarItems(trailing: EditButton())
            .navigationTitle("select a node")
        }
        .onAppear(perform: { nodePers.loadSampleData()} )
    }
}

struct NodeWalkerView: View {
    @EnvironmentObject var nodePers: NodePersistence
    //This breaks observation, it has no SwiftUI wrapper
    var nodes: [Node]
    
    var body: some View {
        Text(nodes.count.description)
        ForEach(nodes, id: \.objectID ) { node in
            NavigationLink(node.name.bound, destination: {
                NodeListWalkerCellView(node: node)
            })
        }
        .onDelete { (indexSet) in
            for idx in indexSet{
                nodePers.delete(nodes[idx])
            }
        }
    }
}

struct NodeListWalkerCellView: View {
    @EnvironmentObject var nodePers: NodePersistence
    
    @ObservedObject var node: Node
    
    var body: some View {
        Section {
            //added
            TextField("name",text: $node.name.bound) //<---Edit HERE
                .textFieldStyle(.roundedBorder)
            if node.children?.allObjects.count ?? -1 > 0{
                NavigationLink(node.name.bound, destination: {
                    NodeWalkerView(nodes: node.children?.allObjects.typeArray() ?? [])
                        .padding(.leading, 30)
                })
            }else{
                Text("empty has no children")
            }
            
        }.navigationTitle("Edit name on this screen")
    }
}

extension Array where Element: Any{
    func typeArray<T: Any>() -> [T]{
        self as? [T] ?? []
    }
}
struct NodeContentView_Previews: PreviewProvider {
    static var previews: some View {
        NodeContentView()
    }
}
extension Optional where Wrapped == String {
    var _bound: String? {
        get {
            return self
        }
        set {
            self = newValue
        }
    }
    var bound: String {
        get {
            return _bound ?? ""
        }
        set {
            _bound = newValue
        }
    }
    
}
///Generic CoreData Helper not needed just to make stuff easy.
class CoreDataPersistence: ObservableObject{
    //Use preview context in canvas/preview
    //The setup for this is in XCode when you create a new project
    let context = ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" ? PersistenceController.preview.container.viewContext : PersistenceController.shared.container.viewContext
    
    init(){
        //Observe all the changes in the context, then refresh the View that observes this using @StateObject, @ObservedObject or @EnvironmentObject
        //There are other options, like NSPersistentStoreCoordinatorStoresDidChange for the coordinator
        //https://developer.apple.com/documentation/foundation/nsnotification/name/1506884-nsmanagedobjectcontextobjectsdid
        //NotificationCenter.default.addObserver(self, selector: #selector(refreshView), name: Notification.Name.NSManagedObjectContextObjectsDidChange, object: nil)
    }
    ///Creates an NSManagedObject of any type
    func create<T: NSManagedObject>() -> T{
        T(context: context)
        //Can set any defaults in awakeFromInsert() in an extension for the Entity
        //or override this method using the specific type
    }
    ///Updates an NSManagedObject of any type
    func update<T: NSManagedObject>(_ obj: T){
        //Make any changes like a last modified variable
        save()
    }
    ///Creates a sample
    func addSample<T: NSManagedObject>() -> T{
        
        return create()
    }
    ///Deletes  an NSManagedObject of any type
    func delete(_ obj: NSManagedObject){
        context.delete(obj)
        save()
    }
    func resetStore(){
        context.rollback()
        save()
    }
    internal func save(){
        do{
            try context.save()
        }catch{
            print(error)
        }
    }
    @objc
    func refreshView(){
        objectWillChange.send()
    }
}

CoreDataPersistence is a generic class that can be used with any entity. Just copy it into you project and you can use it as a superclass for your own CoreData ViewModels or use it as is if you don't have anything to override or add.

The key part of the solution is the line that is uncommented, and the selector that tells the View to reload. Everything else is extra

The code seems like a lot because this is what was provided by the OP but the solution is contained in CoreDataPersistence. Notice the NodeContentView too the context should match the @FetchRequest with theCoreDataPersistence

Option 2

For this specific use case (children are of the same type as the parent) you can use List with children in the init it simplifies a lot of the setup and updating issues are greatly reduced.

extension Node {
    public override func awakeFromInsert() {
        super.awakeFromInsert()
        self.createdAt = Date()
    }
    @objc
    var typedChildren: [Node]?{
        self.children?.allObjects.typeArray()
    }
}
struct NodeListView: View {
    @EnvironmentObject var nodePers: NodePersistence
    @FetchRequest(sortDescriptors: [NSSortDescriptor(keyPath: \Node.createdAt, ascending: true)], predicate: NSPredicate(format: "parent == nil"))
    var nodes: FetchedResults<Node>
    
    var body: some View {
        NavigationView {
            List(Array(nodes) as [Node], children: \.typedChildren){node in
                NodeListWalkerCellView(node: node)
            }
            
            .navigationBarItems(trailing: EditButton())
            .navigationTitle("select a node")
        }
        .onAppear(perform: { nodePers.loadSampleData()} )
    }
}

struct NodeListWalkerCellView: View {
    @EnvironmentObject var nodePers: NodePersistence
    @ObservedObject var node: Node
    
    var body: some View {
        HStack {
            //added
            TextField("name",text: $node.name.bound)
                .textFieldStyle(.roundedBorder)
            Button("delete", role: .destructive, action: {
                nodePers.delete(node)
            })
            
        }.navigationTitle("Edit name on this screen")
    }
}
lorem ipsum
  • 21,175
  • 5
  • 24
  • 48
  • Thanks for the effort, I think `NotificationCenter.default.addObserver` should work in any case, I think my case is quite different from this, however since the bounty was awarded for this question, I will accept it. I will create a separate question – user1046037 Feb 02 '22 at 21:35
  • 1
    @user1046037 just tag me on it when you do and I'lll look at it if you do. – lorem ipsum Feb 02 '22 at 21:50