Update SwiftUI View on NSManagedObject edit

I'm trying to update a SwiftUI (sub) view on property update of NSManagedObject (Training).
The property is an NSOrderedSet (exerciceHistories).

Piece of Training class :
Code Block
extension Training {
...
@NSManaged public var date: Date
@NSManaged public var exerciceHistories: NSOrderedSet
}


The main view is TrainingDetail, displaying detail of Training :
Code Block
struct TrainingDetail: View {
@Environment(\.managedObjectContext) var managedObjectContext
@ObservedObject var training: Training
...
var body: some View {
ZStack {
...
ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: 0) {
...
TrainingDetailAction(training: training, new: new) //-> View that creates sheet to edit one element of training property NSOrderedSet (`exerciceHistories`)
TrainingDetailExercices(training: training, editing: $new) //-> View displaying training property NSOrderedSet (`exerciceHistories`)
.padding()
}}
....
}
...
}
}

Interesting part of TrainingDetailExercices, where editing sheet is opened :
Code Block
struct TrainingDetailExercices: View {
@Environment(\.managedObjectContext) var managedObjectContext
@ObservedObject var training: Training
var body: some View {
...
ForEach(training.exerciceHistories.array as! [ExerciceHistory], id: \.self) {
exerciceHistory in
ExerciceHistoryRow(exerciceHistory: exerciceHistory)
.padding(.bottom, 10)
}.sheet(isPresented: $showEditView, onDismiss: {
...
}, content: {
TrainingAddExercice(training: self.training, ...)
.environment(\.managedObjectContext, self.managedObjectContext)
})

TrainingAddExercice that is the sheet displayed, where we can edit one element of training property NSOrderedSet (exerciceHistories):
Code Block
struct TrainingAddExercice: View {
...
@ObservedObject private var trainingAddExerciceViewModel: TrainingAddExerciceViewModel
@ObservedObject var training: Training
...
}


The function that save and close sheet in TrainingAddExercice :

Code Block
private func trailingNavButtons() -> some View {
HStack{
....
Button(action: {
self.trainingAddExerciceViewModel.save(exerciceHistory: self.exerciceHistory, to: self.managedObjectContext, for: self.training)
/*here I'm trying to force update by creating a new NSMutableOrderedSet and assign it to training (because NSMutableOrderedSet is class, not struct) */
let newExercicesHistories = NSMutableOrderedSet(orderedSet: self.training.exerciceHistories)
self.training.exerciceHistories = newExercicesHistories
self.show = false
}, label: {
...
}).disabled(...)
}
}


One thing I don't understand is that when I'm adding/deleting one ExerciceHistory to/of training.exerciceHistories, view updates but not when I'm editing one even so I'm trying to force by creating new OrderedSet.

Object is well update in CoreData.
Answered by langy in 626337022
After spending a long time trying to understand the behavior and using all my knowledge of SwiftUI, CoreData and Combine, I finally "fix" by adding id to my ForEach like this :

Code Block
ForEach(training.exerciceHistories.array as! [ExerciceHistory], id: \.self) {
exerciceHistory in
ExerciceHistoryRow(exerciceHistory: exerciceHistory)
.padding(.bottom, 10)
}.id(UUID())

hi,

i would stay away from trying to manage the NSOrderedSet yourself via replacement, and instead rely on using the accessors generated by XCode for your Training class. my guess is that these do the right thing with generating objectWillChange messages.

if you want to add a new element to exerciceHistories, just use the accessor addToExerciceHistories(_ value: ExerciceHistory), assuming that ExerciceHistory is the class name of things in the exerciceHistories NSOrderedSet. same thing for deleting and reordering. even if just editing one, i would use an accessor -- maybe one of replaceExerciceHistories(at: with:), or removeFrom... and insertInto...

as a fallback, if things are not updating in certain operations, you could explicitly call .objectWillChange.send() on a Training object and see if that does it for you.

hope that helps,
DMG
Already try to force with self.training.objectWillChange.send(), not working. I struggled a lot with this problem and I'm very confused about what I missed.

Using generated accessor is not working too.
Accepted Answer
After spending a long time trying to understand the behavior and using all my knowledge of SwiftUI, CoreData and Combine, I finally "fix" by adding id to my ForEach like this :

Code Block
ForEach(training.exerciceHistories.array as! [ExerciceHistory], id: \.self) {
exerciceHistory in
ExerciceHistoryRow(exerciceHistory: exerciceHistory)
.padding(.bottom, 10)
}.id(UUID())

hi,

interesting. the id's of the ExerciceHistory object as an Identifiable are not changing when an object has a content change; but .id: \.self does come back as a different value when an item is edited, since it's basically a hash value of the object.

i would think it enough to write only
Code Block
ForEach(training.exerciceHistories.array as! [ExerciceHistory], id: \.self) { exerciceHistory in
ExerciceHistoryRow(exerciceHistory: exerciceHistory)
.padding(.bottom, 10)
}

without adding the extra .id(UUID) at the end. i think the extra UUID would cause all rows of the list to be redrawn, not just the one row that changed.

if fact, seeing what you've written, some testing i was doing elsewhere suggests you could write this as well:

Code Block
ForEach(training.exerciceHistories.array as! [ExerciceHistory]) { exerciceHistory in
ExerciceHistoryRow(exerciceHistory: exerciceHistory)
.id(exerciceHistory.hashValue)
.padding(.bottom, 10)
}


thanks for the idea,
DMG


hi,

I've looked at this more carefully today, and I am not sure the problem is really solved.

I think there are three cases.

(1) ExerciceHistoryRow(exerciceHistory: exerciceHistory) accepts its incoming argument as a simple var and does a simple View based on this var exerciceHistory -- e.g., a simple Text(exerciceHistory.name!) and some other items.

I think this will fail. once that ExerciceHistoryRow is put out in the SwiftUI-sphere, it's out there and managed by SwiftUI and will not be changed because it is fully determined.

the same holds true if you implement an .onAppear() in ExerciceHistoryRow to load a @State variable for the name. that @State var will never be changed in the future.

(2) ExerciceHistoryRow(exerciceHistory: exerciceHistory) accepts its incoming argument as an @ObservedObject var, and that will update correctly. but if you ever delete it, you are looking at a certain crash.

(3) instead, if you write something like ExerciceHistoryRow(name: exerciceHistory.name!) and you use @State var name: String for the View, that's likely to work and be updated properly. every redraw of the List will reset the @State variable and the row view will be updated.

I'll be curious to know what you find.

hope that helps,
DMG
hi,

one correction to my last reply: point number (3) wasn't quite right (i had it backwards; sorry, it was the last thing i wrote before heading off to bed). it should have been:

(3) instead, if you write something like ExerciceHistoryRow(name: exerciceHistory.name!) and you use var name: String for the View, that will be updated properly. be sure to NOT define this variable using @State, because that gives ownership of the View to SwiftUI.

and in general, you'll obviously want to pass along more than just date for a single field in the object ...

my conclusions about this whole "display not updating" situation, especially with Core Data objects (one i've been struggling with for some time):
  • passing a reference to an object to a "RowView" works fine if it is an @ObservedObject -- and if the RowView makes any changes to the object, you'll know about it. however, deleting that object will crash if the RowView is still held onto by SwiftUI when the deletion occurs. (i think the situation is even more murky when using Core Data and @FetchRequest.)

  • passing a struct is the way to go whenever you want a view to be a "display-only," such as a row in a List where by tapping, you will navigate to a separate, detail view. however, do not make that a @State variable in the "RowView" -- once set on creation, SwiftUI owns it and you can't change or reset it from outside the view. (in XCode 11)

  • if you really want to pass an object to such a "display-only" view, offload the data from the object you want displayed into a custom struct that copies the correct fields and information from the object, and pass that struct. again, do not make that a @State variable.

i'd love to hear from others on this!

hope that helps,
DMG
Thanks DMG, I was also struggling with this issue using Core Data and also tried various things incl. the @ObservedObject way ... which as you've mentioned results in a crash when deleting the MO.

Creating a separate "ViewModel"-Struct solved that issue and seems like a reasonable solution.
Update SwiftUI View on NSManagedObject edit
 
 
Q