Search code examples
core-dataswiftui

Refresh a view when the children of an object are changed in SwiftUI


I am working on a CoreData application with two entities MyList and MyListItem. MyList can have many MyListItem (one to many). When the app is launched, I can see all the lists. I can tap on a list to go to the list items. On that screen, I tap a button to add an item to the selected list. After, adding the item when I go back to the all lists screen I cannot see the number of items reflected in the count. The reason is that MyListsView is not rendered again since the number of lists have not changed.

The complete code is shown below:

import SwiftUI
import CoreData

extension MyList {
    
    static var all: NSFetchRequest<MyList> {
        let request = MyList.fetchRequest()
        request.sortDescriptors = []
        return request
    }
}

struct DetailView: View {
    
    @Environment(\.managedObjectContext) var viewContext
    let myList: MyList
    
    var body: some View {
        VStack {
            Text("Detail View")
            Button("Add List Item") {
                
                let myListP = viewContext.object(with: myList.objectID) as! MyList
                
                let myListItem = MyListItem(context: viewContext)
                myListItem.name = randomString()
                myListItem.myList = myListP
                
                try? viewContext.save()
                
            }
        }
    }
    
    func randomString(length: Int = 8) -> String {
        let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
        return String((0..<length).map{ _ in letters.randomElement()! })
    }
}

class ViewModel: NSObject, ObservableObject {
    
    @Published var myLists: [MyList] = []
    
    private var fetchedResultsController: NSFetchedResultsController<MyList>
    private(set) var context: NSManagedObjectContext
    
    override init() {
        self.context = CoreDataManager.shared.context
        
        fetchedResultsController = NSFetchedResultsController(fetchRequest: MyList.all, managedObjectContext: context, sectionNameKeyPath: nil, cacheName: nil)
        super.init()
        fetchedResultsController.delegate = self
        
        do {
            try fetchedResultsController.performFetch()
            guard let myLists = fetchedResultsController.fetchedObjects else { return }
            self.myLists = myLists
            
        }  catch {
            print(error)
        }
    }
}

extension ViewModel: NSFetchedResultsControllerDelegate {
    func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
        guard let myLists = controller.fetchedObjects as? [MyList] else { return }
        self.myLists = myLists
    }
}

struct MyListsView: View {
    
    let myLists: [MyList]
    
    var body: some View {
        List(myLists) { myList in
            NavigationLink {
                DetailView(myList: myList)
            } label: {
                HStack {
                    Text(myList.name ?? "")
                    Spacer()
                    
                    Text("\((myList.items ?? []).count)")
                }
            }
        }
    }
}

struct ContentView: View {
    
    @StateObject private var vm = ViewModel()
    @Environment(\.managedObjectContext) var viewContext
    
    var body: some View {
        NavigationView {
            VStack {
               
                // when adding an item to the list the MyListView view is
                // not re-rendered
                MyListsView(myLists: vm.myLists)
                    
                Button("Change List") {
                   
                }
            }
        }
    }
    
    func randomString(length: Int = 8) -> String {
        let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
        return String((0..<length).map{ _ in letters.randomElement()! })
    }
}

Inside ContentView there is a view called "MyListsView". That view is not rendered when the items are added. Since, according to that view nothing changed since the number of lists are still the same.

How do you solve this problem?

UPDATE:

What happens if I add one more level of views like for ListCellView as shown below:

struct MyListCellView: View {
    
    @StateObject var vm: ListCellViewModel
    
    init(vm: ListCellViewModel) {
        _vm = StateObject(wrappedValue: vm)
    }
    
    var body: some View {
        HStack {
            Text(vm.name)
            Spacer()
            
            Text("\((vm.items).count)")
        }
    }
}

@MainActor
class ListCellViewModel: ObservableObject {
    
    let myList: MyList
    
    init(myList: MyList) {
        self.myList = myList
        self.name = myList.name ?? ""
        self.items = myList.items!.allObjects as! [MyListItem]
        print(self.items.count)
    }
    
    @Published var name: String = ""
    @Published var items: [MyListItem] = []
}

struct MyListsView: View {
    
    @StateObject var vm: ViewModel
    
    init(vm: ViewModel) {
        _vm = StateObject(wrappedValue: vm)
    }
    
    var body: some View {
        let _ = Self._printChanges()
        List(vm.myLists) { myList in
            NavigationLink {
                DetailView(myList: myList)
            } label: {
                MyListCellView(vm: ListCellViewModel(myList: myList))
            }
        }
    }
}

Now the count is again not being updated.


Solution

  • Your ViewModel is an ObserveableObject, but you are not observing it in MyListsView. When you initialized MyListsView, you set a let constant. Of course that won't update. Do this instead:

    struct MyListsView: View {
        
        @ObservedObject var vm: ViewModel
    
        init(viewModel: ViewModel) {
            self.vm = viewModel
        }
        
        var body: some View {
            List(vm.myLists) { myList in
                NavigationLink {
                    DetailView(myList: myList)
                } label: {
                    HStack {
                        Text(myList.name ?? "")
                        Spacer()
                        
                        Text("\((myList.items ?? []).count)")
                    }
                }
            }
        }
    }
    

    Now the @Published in ViewModel will cause MyListView to change when it does, and that includes adding a related entity.