Using SwiftUI, Core Data, and one-to-many relationships, why does the list not update when adding a row on the Many side

笑着哭i 提交于 2020-12-26 10:58:13

问题


I have a SwiftUI project with Core Data. The data model is a simple one-to-many and two primary views which each have a textfield at the top and a button to add a new item to the list view below. The first view is for the One side of the relation and the second for the Many. So, the NavigationLink in the first opens the second and passes the One object. Pretty standard stuff, it would seem. The methodology for creating the One works and the list below gets updated immediately when the managed object context saves the new item. But, the same type of methodology doesn't refresh the list for the Many side when viewing on a device, although it does work fine in the simulator and the preview window. The data is definitely saved because if you navigate back to the One side then re-select it to re-load the Many view, it shows the new item in the list.

I've looked through lots of tutorials, other questions, etc. and haven't found a reason for this. Am I doing something wrong in how I am going to the Many side of the relation, or is there something else I have to do to refresh the view only on the Many side? Thanks!!!

Full project available at https://github.com/fahrsoft/OneToManyTest

From the ContentView, showing the One side (note: OneView is a simple view that takes the object and shows the text. Same for ManyView.):

struct ContentView: View {
@Environment(\.managedObjectContext) var moc
@FetchRequest(entity: One.entity(), sortDescriptors: []) var ones: FetchedResults<One>

@State private var newName = ""
@State var isNavTitleHidden = true

var body: some View {
    NavigationView {
        VStack {
            HStack {
                TextField("New One", text: self.$newName)
                Spacer()
                Button(action: {
                    let newOne = One(context: self.moc)
                    newOne.name = self.newName
                    self.newName = ""
                    try? self.moc.save()
                }) {
                    Image(systemName: "plus.circle.fill")
                        .foregroundColor(.green)
                        .frame(width: 32, height: 32, alignment: .center)
                }
            }
            .padding(.top)
            .padding(.horizontal)

            List {
                Section(header: Text("Ones")) {
                    ForEach(self.ones, id:\.self) { (one:One) in
                        NavigationLink(destination: OneDetailView(one: one, isNavTitleHidden: self.$isNavTitleHidden).environment(\.managedObjectContext, self.moc)) {
                            OneView(one: one).environment(\.managedObjectContext, self.moc)
                        }
                    }

                    .onDelete { indexSet in
                        let deleteOne = self.ones[indexSet.first!]
                        self.moc.delete(deleteOne)
                        do {
                            try self.moc.save()
                        } catch {
                            print(error)
                        }
                    }
                }
            }
        }
        .navigationBarTitle(Text("Ones List"))
        .navigationBarHidden(self.isNavTitleHidden)
        .onAppear {
            self.isNavTitleHidden = true
        }
    }
}}

From the OneDetailView showing the Many side:

struct OneDetailView: View {
@Environment(\.managedObjectContext) var moc

@ObservedObject var one: One

@State private var newManyAttribute = ""
@Binding var isNavTitleHidden: Bool

var body: some View {
    VStack {
        HStack {
            TextField("New Many", text: self.$newManyAttribute)
            Spacer()
            Button(action: {
                let newMany = Many(context: self.moc)
                newMany.attribute = self.newManyAttribute
                self.newManyAttribute = ""
                self.one.addToMany(newMany)
                try? self.moc.save()
            }) {
                Image(systemName: "plus.circle.fill")
                    .foregroundColor(.green)
                    .frame(width: 32, height: 32, alignment: .center)
            }
        }
        .padding(.top)
        .padding(.horizontal)

        List {
            Section(header: Text("Manys")) {
                ForEach(self.one.manyArray, id: \.self) { many in
                    ManyView(many: many).environment(\.managedObjectContext, self.moc)
                }
            }
        }
    }
    .navigationBarTitle("\(self.one.wrappedName) Details")
    .onAppear {
        self.isNavTitleHidden = false
    }
}}

回答1:


The only thing I could come up with as a way to make it work decently well was to create a new FetchRequest for the Many items using the selected One in a predicate. Adding the FetchRequest and an init to the beginning of the OneDetailView allows for the list to update.

struct OneDetailView: View {
@Environment(\.managedObjectContext) var moc

@ObservedObject var one: One

@State private var newManyAttribute = ""
@Binding var isNavTitleHidden: Bool

@FetchRequest var manys: FetchedResults<Many>

init(one: One, isNavTitleHidden: Binding<Bool>) {
    self.one = one
    self._isNavTitleHidden = isNavTitleHidden
    var predicate: NSPredicate?
    predicate = NSPredicate(format: "one = %@", one)
    self._manys = FetchRequest(
        entity: Many.entity(),
        sortDescriptors: [],
        predicate: predicate
    )
}

var body: some View {
    VStack {
        HStack {
            TextField("New Many", text: self.$newManyAttribute)
            Spacer()
            Button(action: {
                let newMany = Many(context: self.moc)
                newMany.attribute = self.newManyAttribute
                self.newManyAttribute = ""
                self.one.addToMany(newMany)
                try? self.moc.save()
            }) {
                Image(systemName: "plus.circle.fill")
                    .foregroundColor(.green)
                    .frame(width: 32, height: 32, alignment: .center)
            }
        }
        .padding(.top)
        .padding(.horizontal)

        List {
            Section(header: Text("Manys")) {
                ForEach(self.manys, id: \.self) { many in
                    ManyView(many: many).environment(\.managedObjectContext, self.moc)
                }
            }
        }
    }
    .navigationBarTitle("\(self.one.wrappedName) Details")
    .onAppear {
        self.isNavTitleHidden = false
    }
}}



回答2:


I've found a fix/workaround that's literally just a few lines of code and seems to work great.

What's happening as you've seen is CoreData isn't announcing anything has changed when a relationship changes (or for that matter a relation to a relation). So your view struct isn't getting reinstantiated and it's not querying those computed properties on your core data object. I've been learning SwiftUI and trying to rewrite a UI to use a Model that uses a few different relationships, some nested.

My initial thought was to use subviews with @FetchRequests and pass in parameters to those views. But I've got a lot of subviews that need to make use of relationships - that's a ton of code, and for me could potentially be tens if not hundreds of fetchrequests for some layouts. I'm no expert, but that way lies madness!

Instead I've found a way that seems hackish, but uses very little code and feels kind of elegant for a cheat.

I have a ModelController class that handles all Core Data code on a background context and I use that context to 'kick' the ui to tell it to refresh itself when it saves (any time something changes). To do the kicking, I added a @Published kicker property to the class which any views can use to be notified when they need to be torn down and rebuilt. Any time the background context saves, the kicker toggles and that kick is pushed out into the environment.

Here's the ModelController:

    public class ModelController: ObservableObject {
   
    // MARK: - Properties
    
    let stack: ModelStack
    
    public let viewContext: NSManagedObjectContext
    public let workContext: NSManagedObjectContext
        
    @Published public var uiKicker = true
    
    // MARK: - Public init
    
    public init(stack: ModelStack) {
        
        self.stack = stack
        
        viewContext = stack.persistentContainer.viewContext
        viewContext.automaticallyMergesChangesFromParent = true
        
        workContext = stack.persistentContainer.newBackgroundContext()
        workContext.automaticallyMergesChangesFromParent = true
    }
    
// Logic logic...

    public func save() {
        workContext.performAndWait {
            if workContext.hasChanges {
                do {
                    try self.workContext.save()
                } catch {
                    fatalError(error.localizedDescription)
                }
            }
        }            
        uiKicker.toggle()
    }
}

I currently instantiate ModelController in @main and inject it into the environment to do my bidding:

@main
struct MyApp: App {
    let modelController = ModelController(stack: ModelStack())
    var body: some Scene {
        WindowGroup {
            MainView()
                .environment(\.managedObjectContext, modelController.viewContext)
                .environmentObject(modelController)
        }
    }
}

Now take a view that isn't responding... Here's one now! We can use the uiKicker property to force the stubborn view to refresh. To do that you need to actually use the kicker's value somewhere in your view. It doesn't apparently need to actually change something, just be used - so for example in this view you'll see at the very end I'm setting the opacity of the view based on the uiKicker. It just happens the opacity is set to the same value whether it's true or false so this isn't a noticeable change for the user, other than the fact that the 'sticky' value (in this case list.openItemsCount) gets refreshed.

You can use the kicker anywhere in the UI and it should work (I've got it on the enclosing VStack but it could be anywhere in there).

struct CardView: View {
    @ObservedObject var list: Model.List
    @EnvironmentObject var modelController: ModelController
    var body: some View {
        VStack {
            HStack {
                Image(systemName: "gear")
                Spacer()
                Label(String(list.openItemsCount), systemImage: "cart")
            }
            Spacer()
            Text(list.name ?? "Well crap I don't know.")
            Spacer()
            HStack {
                Image(systemName: "trash")
                    .onTapGesture {
                        modelController.delete(list.objectID)
                    }
                Spacer()
                Label("2", systemImage: "person.crop.circle.badge.plus")
            }
        }
        .padding()
        .background(Color.gray)
        .cornerRadius(30)
        .opacity(modelController.uiKicker ? 100 : 100)
    }
}

And there you have it. Use the uiKicker anywhere things aren't refreshing properly. Literally a few lines of code and stale relationships are a thing of the past!

As I learn more about SwiftUI I have to say I'm loving it!

EDIT TO ADD:

Poking around some more I've found that this only works if the observed object is injected using .environmentObject, it doesn't work if you use custom environment keys and inject using .environment(\.modelController). I have no idea why but it's true as of iOS 14.3/XCode 12.3.



来源:https://stackoverflow.com/questions/60938772/using-swiftui-core-data-and-one-to-many-relationships-why-does-the-list-not-u

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!