26

I'm trying to make an edit form that can take a value as a @Binding, edit it, and commit the change. In this case, I'm populating a list with Core Data records using the @FetchRequest property wrapper. I want to tap on a row to navigate from the List to a Detail view, then on the Detail view I want to navigate to the Edit view.

Example image

I tried doing this without the @Binding and the code will compile but when I make an edit, it is not reflected on the previous screens. It seems like I need to use @Binding but I can't figure out a way to get a NSManagedObject instance inside of a List or ForEach, and pass it to a view that can use it as a @Binding.

List View

struct TimelineListView: View {

    @Environment(\.managedObjectContext) var managedObjectContext

    // The Timeline class has an `allTimelinesFetchRequest` function that can be used here
    @FetchRequest(fetchRequest: Timeline.allTimelinesFetchRequest()) var timelines: FetchedResults<Timeline>

    @State var openAddModalSheet = false

    var body: some View {

        return NavigationView {
            VStack {
                List {

                    Section(header:
                        Text("Lists")
                    ) {
                        ForEach(self.timelines) { timeline in

                            // ✳️ How to I use the timeline in this list as a @Binding?

                            NavigationLink(destination: TimelineDetailView(timeline: $timeline)) {
                                TimelineCell(timeline: timeline)
                            }
                        }
                    }
                    .font(.headline)

                }
                .listStyle(GroupedListStyle())

            }

            .navigationBarTitle(Text("Lists"), displayMode: .inline)

        }

    } // End Body
}

Detail View

struct TimelineDetailView: View {

    @Environment(\.managedObjectContext) var managedObjectContext

    @Binding var timeline: Timeline

    var body: some View {

        List {

            Section {

                NavigationLink(destination: TimelineEditView(timeline: $timeline)) {
                    TimelineCell(timeline: timeline)
                }

            }

            Section {

                Text("Event data here")
                Text("Another event here")
                Text("A third event here")

            }


        }.listStyle(GroupedListStyle())

    }
}

Edit View

struct TimelineEditView: View {

    @Environment(\.managedObjectContext) var managedObjectContext

    @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>

    @State private var newDataValue = ""

    @Binding var timeline: Timeline

    var body: some View {

        return VStack {

            TextField("Data to edit", text: self.$newDataValue)
                .shadow(color: .secondary, radius: 1, x: 0, y: 0)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .onAppear {
                    self.newDataValue = self.timeline.name ?? ""
            }.padding()
            Spacer()
        }

            .navigationBarItems(
                leading:
                Button(action: ({
                    // Dismiss the modal sheet
                    self.newDataValue = ""
                    self.presentationMode.wrappedValue.dismiss()

                })) {
                    Text("Cancel")
                },

                trailing: Button(action: ({

                    self.timeline.name = self.newDataValue


                    do {
                        try self.managedObjectContext.save()
                    } catch {
                        print(error)
                    }

                    // Dismiss the modal sheet
                    self.newDataValue = ""
                    self.presentationMode.wrappedValue.dismiss()

                })) {
                    Text("Done")
                }
        )

    }
}

I should mention, the only reason I'm even trying to do this is because the modal .sheet() stuff is super buggy.

11
  • Do you want to save on any single character text change or when the user presses a button? Commented Aug 22, 2019 at 18:01
  • I want to navigate to a view with various data entry controls like forms and pickers. I want a cancel button to discard changes, and a Done button to save changes. If you take a look at the List creation and editing features in the iOS13 Reminders app, that is pretty much what I want, except I've had nothing but issues with the modal .sheet() modifier. Commented Aug 22, 2019 at 18:06
  • Hm. I am not sure how one usually saves data from FetchedResults, but I guess any entry can be saved from anywhere. So you don't need a @Binding on specific entry since saving (moc.save()) it means it updates your source of truth automatically. That's my best guess. Commented Aug 22, 2019 at 18:10
  • 2
    When I do this without the @Binding though, I can save the change into the MOC and that part works, but the views on the previous screens do not update. I have to quit and restart the app to see the changes reflected. Commented Aug 22, 2019 at 18:14
  • 1
    I've had issues trying to use Binding, but better luck with @ObservedObject Commented Aug 23, 2019 at 4:55

2 Answers 2

13

To implement creation and editing functionality with Core Data it is best to use nested managed object contexts. If we inject a child managed object context, derived from the main view context, as well as the managed object being created or edited that is associated with a child context, we get a safe space where we can make changes and discard them if needed without altering the context that drives our UI.

    let childContext = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType)
    childContext.parent = viewContext
    let childItem = childContext.object(with: objectID) as! Item
    return ItemHost(item: childItem)
        .environment(\.managedObjectContext, childContext)

Once we are done with our changes, we just need to save the child context and the changes will be pushed up to the main view context and can be saved right away or later, depending on your architecture. If we are unhappy with our changes we can discard them by calling rollback() on our child context.

    childContext.rollback()

Regarding the question of binding managed objects with SwiftUI views, once we have our child object injected into our edit form, we can bind its properties directly to SwiftUI controls. This is possible since NSManagedObject class conforms to ObservableObject protocol. All we have to do is mark a property that holds a reference to our child object with @ObservedObject and we get its bindings. The only complication here is that there are often type mismatches. For example, managed objects store strings as String?, but TextField expects String. To go around that we can use Binding’s initializer init?(_ base: Binding<Value?>).

We can now use our bindings, provided that the name attribute has a default empty string set in the managed object model, or else this will crash.

    TextField("Title", text: Binding($item.title)!)

This way we can cleanly implement the SwiftUI philosophy of no shared state. I have provided a sample project for further reference.

Sign up to request clarification or add additional context in comments.

3 Comments

Thank you so much for posting this along with the GitHub sample. It was exactly what I was looking for to get me going :)
Quick question. This works beautifully BUT is there a simple way to hook it up to CloudKit?
I personally haven’t researched it yet. But when I do, I’ll add more info. This approach, however, will stay the same even with CloudKit. Contexts are fundamental part of Core Data since it uses the command pattern.
6

@Binding only works with structs.

But CoreData result are Objects (NSManagedObject adopting ObservableObject). You need to use @ObservedObject to register for changes.

4 Comments

can you please elaborate on how to achieve this? I tried marking the object timeline in the Detail View as @ObservedObject but the change is not propagated to the parent view. I also tried saving the context, everytime there is a change in the Detail View but still the parent view is not updated.
Hard to fully answer your question, as I cannot make the provided code compile with reasonable effort. But both @Bindings to timeline in both DetailView and EditView need to be made @ObservedObject
Also, while you are dealing with CoreData objects: In some contexts, I would have to register to NotificationCenter.Publisher(.NSManagedObjectContextChanged) notification to force UI redraw.
Additionally, CoreData again: to force trigger view redraw I sometimes had to send object change notifications: <observableObject>.objectDidChange.send()

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.