1

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
}()
Sheffield
  • 355
  • 4
  • 18
  • "Correct" is opinion based, which is out of scope here. It is a way to do this and yes it gets added. But until you `save` it is in a sort of cache. it will disappear if you restart the app. With `NSFetchRequest` you can exclude these items and the re context can be reset/rollback to get rid of any pending changes. [Here](https://stackoverflow.com/questions/71055786/swiftui-saving-likes-in-coredata-for-each-individual-cell/71058020#71058020) is a similar setup. – lorem ipsum Mar 10 '22 at 16:29
  • In Core Data, see what @loremipsum wrote. If you want to evaluate what comes back from the sheet before creating the database item, you can create a temporary `Item` struct that is used only between the view and the sheet. When you have validated the temporary object, you can create the Core Data `Item`. – HunterLion Mar 10 '22 at 16:36

2 Answers2

2

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)
                }
            }
    }
}
malhal
  • 26,330
  • 7
  • 115
  • 133
  • 1
    Thanks for letting me know the idea of "child context". If I embed one more sheet in `ItemEditor` (e.g. editing more settings there) with the similar "cancel-save" pattern, I will have to create another "child context" and save-callback, right? – Sheffield Mar 11 '22 at 03:50
  • 1
    Yes you can do it that way. If you are new to child contexts you might want to read the docs. Basically when you save the child context it pushes the changes up to the parent context. You have to learn how to merge changes from parent back down to children if you want that. You need to also learn the different merge behaviors. Here is the doc, see from "When you save changes in a context," https://developer.apple.com/documentation/coredata/nsmanagedobjectcontext – malhal Mar 11 '22 at 13:37
-1

You added the item to the ViewContext, but you have not persisted it to storage. If you click you "+" button, it appears in the list, but if you stop the app and reopen it, you will find the item is not there.

Think of the view context as a world of data that is created from the persistent store. You can put things in and change things, but they are not changed in the store until you call save(). In this way, you have done nothing different than if you used a data model in a view model with an @Published var like this:

class ViewModel: ObservableObject {
    @Published items: [Items]
    
    init() {
        ...
    }
}

struct item: Identifiable {
    let id = UUID()
    var timeStamp: Date
}

If you implemented this instead of Core Data, you would see the same behavior, but for the save()

Yrb
  • 8,103
  • 2
  • 14
  • 44
  • "view models have no place in SwiftUI" https://stackoverflow.com/a/60883764/259521 – malhal Mar 11 '22 at 13:38
  • I think you missed the point of the answer AND are injecting opinion. This was a demonstration as to how Core Data works if you don't use a `.save()`. As Core Data creates a class to manage the objects (whether you see it in your code or not), this is a valid paradigm for someone JUST LEARNING Core Data to be able to relate to. The OP asked why did this happen. – Yrb Mar 11 '22 at 14:50
  • your code isn't core data though – malhal Mar 11 '22 at 15:39
  • I agree. The question was why did the managed object was added to the context before it was saved. My answer was an example of what was happening in a non-Core Data way. The OP is just learning Core Data, and obviously did not understand that creating a managed object adds it to the context, even though the context had not been persisted. The above was the closest non-CD example I could think of to show what was happening. Secondarily he asked about keeping in an `@State` var for editing. OP's code works, though it is naive. This was about understanding why it did what it did. – Yrb Mar 11 '22 at 15:46