0

How do I create a dual entry (add/edit) form for Core Data without needing a ViewModel? If I have a Core Data entity named Scenario and am building a detail view to add or edit where I have a:

@StateObject var scenario: Scenario

but want it to support a nil value, so I can use it for an add as well as an update, but when I change it to:

@StateObject var scenario: Scenario?

I get this error:

Generic struct 'StateObject' requires that 'Scenario?' conform to 'ObservableObject'

I've watched this video by Paul Hudson, but it is just a simple read detail. Also this one is close as well, by Swift Arcade, but it's an edit and not an edit/add. I think for most demos, simple screens which have one attribute, such as "name", seem to be dominate.

In this core data video, Stewart Lynch has a dual form for add/edit, but he's using a ViewModel. I've seen a lot of content regarding not using View Models including this one, which seemed to make sense. Paul Hudson covers the MVVM topic as well.

My form is complex, with relationship records, so I don't want to create two separate forms, one for adding and one for editing.

I'm trying to keep it simple. Any help would be appreciated.

lcj
  • 1,355
  • 16
  • 37
  • `@StateObject` is for creating and owning (reference type) instances. In a detail (child) view you need `@ObservedObject` passing the instance in the `init` method. To add and edit Core Data records efficiently in the same view please watch [this video](https://www.youtube.com/watch?v=P8rqjs_CNsk). And yes, actually you need a view model. – vadian Jun 30 '23 at 19:58
  • https://stackoverflow.com/questions/70869061/swiftui-how-to-fetch-core-data-values-from-detail-to-edit-views/70901886#70901886 Just look for the add and edit views. – lorem ipsum Jul 01 '23 at 08:07
  • That's a good one, @loremipsum. Thank you. I don't know why there's an intermediate view vs creating a new object in a function, but that's likely due to my lack of understanding regarding SwiftUI. It does mean you can create an object without a name or with a temp name....all all required attributes need to be populated, but that's reasonable. – lcj Jul 01 '23 at 11:49
  • The issue is that an ObservableObject is needed upon init. With an edit view you already have it. For adding you have to create it. It is a self contained view just navigate to it and everything else is taken care of. You can use a function if you want. The important part is having it on init. You can create the object with a button and then “select it”. If I need initial values I usually set them with the “awake from insert” method. You can “rollback” or reset upon dismiss of the add view. That way if the user cancels or doesn’t save you get rid of the new one. – lorem ipsum Jul 01 '23 at 12:28
  • @loremipsum, I actually went with your solution vs the one below because I didn't want to have multiple object maintaining various forms of state when core data could do it. – lcj Jul 01 '23 at 19:37
  • @loremipsum I would actually love to see the create object w/button and select it. Can you show an example as a solution and I will select it. – lcj Jul 01 '23 at 19:52
  • Does this answer your question? [Use the same view for adding and editing CoreData objects](https://stackoverflow.com/questions/69330564/use-the-same-view-for-adding-and-editing-coredata-objects) – lorem ipsum Jul 01 '23 at 20:07
  • https://stackoverflow.com/questions/65947186/whats-best-practice-for-programmatic-movement-a-navigationview-in-swiftui/65948209#65948209 – lorem ipsum Jul 01 '23 at 20:10
  • @loremipsum, I got it to work. Can't tell you I love using sheets for this as the object is really complex, but... I will respond with a solution as the dual entry form SO reference you posted uses a view model and I was trying to avoid that. – lcj Jul 03 '23 at 11:42

2 Answers2

0

I'm doing editing like below, using a child context as a "scratch pad" for editing, to prevent edits that are cancelled from affecting the main context:

I'm using a child context like this:

    struct ItemEditorConfig: Identifiable {
        let id = UUID()
        let context: NSManagedObjectContext
        let item: Item
        
        init(viewContext: NSManagedObjectContext, objectID: NSManagedObjectID) {
            // create the scratch pad context
            context = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType)
            context.parent = viewContext
            // load the item into the scratch pad
            item = context.object(with: objectID) as! Item
        }
    }
    
    struct ItemEditor: View {
        @ObservedObject var item: Item // this is the scratch pad item
        @Environment(\.managedObjectContext) private var context
        @Environment(\.dismiss) private var dismiss // causes body to run
        let onSave: () -> Void
        @State var errorMessage: String?
        
        var body: some View {
            NavigationView {
                Form {
                    Text(item.timestamp!, formatter: itemFormatter)
                    if let errorMessage = errorMessage {
                        Text(errorMessage)
                    }
                    Button("Update Time") {
                        item.timestamp = Date()
                    }
                }
                .toolbar {
                    ToolbarItem(placement: .navigationBarLeading) {
                        Button("Cancel") {
                            dismiss()
                        }
                    }
                    ToolbarItem(placement: .navigationBarTrailing) {
                        Button("Save") {
                            // first save the scratch pad context then call the handler which will save the view context.
                            do {
                                try context.save()
                                errorMessage = nil
                                onSave()
                            } catch {
                                let nsError = error as NSError
                                errorMessage  = "Unresolved error \(nsError), \(nsError.userInfo)"
                            }
                        }
                    }
                }
            }
        }
    }

    struct EditItemButton: View {
        let itemObjectID: NSManagedObjectID
        @Environment(\.managedObjectContext) private var viewContext
        @State var itemEditorConfig: ItemEditorConfig?
        
        var body: some View {
            Button(action: edit) {
                Text("Edit")
            }
            .sheet(item: $itemEditorConfig, onDismiss: didDismiss) { config in
                ItemEditor(item: config.item) {
                    do {
                        try viewContext.save()
                    } catch {
                        // Replace this implementation with code to handle the error appropriately.
                        // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
                        let nsError = error as NSError
                        fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
                    }
                    itemEditorConfig = nil // dismiss the sheet
                }
                .environment(\.managedObjectContext, config.context)
            }
        }
        
        func edit() {
            itemEditorConfig = ItemEditorConfig(viewContext: viewContext, objectID: itemObjectID)
        }
        
        func didDismiss() {
            // Handle the dismissing action.
        }
    }
    
    struct DetailView: View {
        @ObservedObject var item: Item
        
        var body: some View {
            Text("Item at \(item.timestamp!, formatter: itemFormatter)")
                .toolbar {
                    ToolbarItem(placement: .navigationBarTrailing) {
                        EditItemButton(itemObjectID: item.objectID)
                    }
                }
        }
    }
malhal
  • 26,330
  • 7
  • 115
  • 133
  • Thank you! Looking through it now. I was going down this road as I really like the idea of making all the changes and being able to discard them...also I had to deal w/nested observable objects. – lcj Jul 01 '23 at 12:24
  • Downside was that I had multiple layers of various types of persistence. – lcj Jul 01 '23 at 12:33
  • I don't think I can do intermediate objects like this. I can already rollback changes in the core data objects and I don't want to have to recreate every entity in the data model. – lcj Jul 03 '23 at 10:35
0

I followed the pattern in a post @loremipsum recommended, but adapted it so I would not have to incorporate a view model, which is a design decision (lots of debate on this but SwiftUI + Core Data can suffice for this example).

I added an object to the view:

@State var newScenario: Scenario? = nil

Added a sheet to view the new item:

        .sheet(item: $newScenario, onDismiss: {
            moc.rollback()
        }, content: { newItem in
            NavigationView{
                ScenarioDetailView(scenario: newItem)
            }
        })

A button to create it:

        .toolbar {
            ToolbarItemGroup (placement: .navigationBarTrailing) {
                Button {
                    createNewScenario()
                } label: {
                    
                    Image(systemName: "plus")
                }
                .padding()
            }
        }

and a function to create the new Scenario causing the sheet to display:

func createNewScenario() {
    var scenario = Scenario(context: moc)
    scenario.name = "New Scenario"
    newScenario = scenario
}
lcj
  • 1,355
  • 16
  • 37