Search code examples
core-dataswiftui

How to use @State to track the item to be add with Core Data in a sheet


I'm learning Core Data but a strange problem confused me at the very beginning. I was using the Empty project created by Xcode and did some modifications to try to implement the feature to add a new entity in a sheet.

I wanted to track the item to be added by a @State value. However, when I open up the sheet, I have already seen the record is added before I executed any try? context.save(). I'd like to use the @State entity to be passed down to the sheet for receiving information there and finally saved to store when hitting "confirm" (not implemented in the code). The reason I pass a whole entity object is that I want to handle Add/Edit in the same sheet.

BTW, is it correct to handle such "Entity adding" scenario like this using @State?

import SwiftUI
import CoreData

struct ContentView: View {
    @Environment(\.managedObjectContext) private var viewContext

    @FetchRequest(
        sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)],
        animation: .default)
    private var items: FetchedResults<Item>

    @State private var itemToAdd: Item?
    
    var body: some View {
        NavigationView {
            List {
                ForEach(items) { item in
                    NavigationLink {
                        Text("Item at \(item.timestamp!, formatter: itemFormatter)")
                    } label: {
                        Text(item.timestamp!, formatter: itemFormatter)
                    }
                }
            }
            .toolbar {
                ToolbarItem {
                    Button {
                        itemToAdd = Item(context: viewContext)
                        itemToAdd?.timestamp = Date()
                    } label: {
                        Label("Add Item", systemImage: "plus")
                    }

                }
            }
            .sheet(item: $itemToAdd) { itemToAdd in
                Text("Empty")
            }
        }
    }
}

private let itemFormatter: DateFormatter = {
    let formatter = DateFormatter()
    formatter.dateStyle = .short
    formatter.timeStyle = .medium
    return formatter
}()

Solution

  • Usually we create a child context to use a "scratch pad" for creating objects. So if cancelled, the context is thrown away without affecting the main context. You can achieve this with a struct that creates a child context and an the new object, and use that struct as your sheet item. E.g.

    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)
                    }
                }
        }
    }