Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

SwiftUI @FetchRequest Core Data Changes to Relationships Don't Refresh

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)
            }
        }
    }
}
like image 557
user192742 Avatar asked Nov 13 '19 21:11

user192742


1 Answers

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")
    }
}
like image 134
lorem ipsum Avatar answered Oct 19 '22 20:10

lorem ipsum