19
votes

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.

3
Do you want to save on any single character text change or when the user presses a button?Fabian
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.radicalappdev
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.Fabian
You just need a binding from the place you want to hold the value in until it is saved, so that you don't have two copies Or do you still want a binding? You can try to use Binding<MyType>.init(set:get:) to build one.Fabian
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.radicalappdev

3 Answers

6
votes

@Binding only works with structs.

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

1
votes

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 refresh(_:mergeChanges:) on our child context while passing in our child object.

    childContext.refresh(item, mergeChanges: false)

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 to mark a property that holds a reference to our child object with @ObservedObject and we get its publishers. 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 extend Binding structure and introduce some proxies like so.

    extension Binding {
        func optionalProxy<Wrapped>() -> Binding<Wrapped>? where Value == Optional<Wrapped> {
            guard let value = self.wrappedValue else { return nil }
            
            return Binding<Wrapped>(
                get: {
                    value
                },
                set: {
                    self.wrappedValue = $0
                }
            )
        }
    }

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: $item.title.optionalProxy()!)

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

0
votes

pseudocode:

// pass value & init child view

List(templates) { template in
    TemplateCell(template: Binding.constant(template)) // init 
}

struct TemplateCell {
    @Binding var template: Template // @Binding for reload cell automatic
}

// TextField + Edit

TextField("Content", text: (Binding($template.content) ?? Binding.constant("")), onEditingChanged: { isEditing in
    // save CoreData here when keyboard hide

}, onCommit: {
    // press enter, you can insert something

})

// combine
.onReceive(self.template.objectWillChange) { _ in
    // can do some thing
}