How do I implement a child context (CoreData) in SwiftUI environment?

Viewed 1826

I'm currently learning SwiftUI to further include it in my daily UIKit-based workflow. I got to a point where I can not get a concept running in SwiftUI, that I've been using for years in UIKit.

The idea is to use a child context of my main CoreData managedObjectContext for editing entities. I implement it quite basically by doing the following:

// Get view context of application
let viewContext = (UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext

// Create NSManagedObjectContext
let editingContext = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType)

// Set the parent of the editing context to the main view context
editingContext.parent = viewContext

By using a separate editingContext I can make changes to an entity in it without the change proceeding directly to my main context. If the user chooses to abort the change, I just reset the editingContext.

For the implementation in SwiftUI, I chose to implement the editingContext as an environment object by creating a custom EnvironmentKey:

struct EditingContextKey: EnvironmentKey {
    static let defaultValue: NSManagedObjectContext = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType)
}

extension EnvironmentValues {
    var editingContext: NSManagedObjectContext {
        get {
            return self[EditingContextKey.self]
        }
        set {
            self[EditingContextKey.self] = newValue
        }
    }
}

Then the editingContext is added to my root view:

let contentView = ContentView()
    .environment(\.managedObjectContext, viewContext)
    .environment(\.editingContext, editingContext)

To this point everything works fine. I also can use the editingContext by calling the following in the respective view.

@Environment(\.editingContext) var editingContext

However, as soon as I make changes to the entity in the editingContext and try to save the editingContext, I get an unexpected error:

Fatal error: Unresolved error Error Domain=Foundation._GenericObjCError Code=0 "(null)", [:]: file

I did already checked, if the context of the edited entity is matching the editingContext. This seems to be the case. I have the feeling, it could have something to do with a binding in SwiftUI, but have no idea how to approach finding a solution.

Has anyone had a similar problem? Or is my basic approach to the desired functionality wrong and there is a more convenient way of doing this?

Thank you!

2 Answers

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

Looking at your approach, I would not recommend injecting two different contexts at the same time into a single view's environment. It's best to have a clear mental picture of whether the view is in the main view context territory or the editing child context territory. If we are working with a view that we know is driven by a nested context, merely dismissing that view will clear out its struct from memory, destroy its context, and thus discard our changes.

We also have to be mindful of where we create our nested contexts. The mistake I used to make is creating a nested context inside a .sheet modifier like so:

.sheet(isPresented: $isPresentingItemEditor) {
    makeItemEditor()
}

func makeItemEditor() -> some View {
     let childContext = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType)
     childContext.parent = viewContext
     let childItem = Item(context: childContext)
     return ItemEditor(item: childItem)
         .environment(\.managedObjectContext, childContext)
}

It works fine at first, but the system might call .sheet modifier many times, and we'll be creating new child contexts for the same operation. As we scale, this might cause the user to lose their changes when the app moves to the background, failures establishing relationships between objects because they are in different contexts.

To remedy this, we can store our nested context, and a newly created object in that context, in a dedicated structure like this:

struct CreateOperation<Object: NSManagedObject>: Identifiable {
    let id = UUID()
    let childContext: NSManagedObjectContext
    let object: Object
    
    init(withParentContext parentContext: NSManagedObjectContext) {
        childContext = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType)
        childContext.parent = parentContext
        object = Object(context: childContext)
    }
}

Now to create a new object and present accompanying UI for editing, we simply need to create an instance of that structure.

struct ItemList: View {
    @Environment(\.managedObjectContext) private var viewContext
    
    @FetchRequest(sortDescriptors: [SortDescriptor(\.name)])
    private var items: FetchedResults<Item>
    
    @State private var configuration: CreateOperation<Item>?
    
    var body: some View {
        NavigationView {
            List {
                ForEach(items) { item in
                    Text(item.name ?? "")
                }
            }
            
            .toolbar {
                Button(action: addItem) {
                    Label("Add Item", systemImage: "plus")
                }
            }
        }
        .sheet(item: $configuration) { configuration in
            ItemEditor(item: configuration.object)
                .environment(\.managedObjectContext, configuration.childContext)
        }
    }
    
    private func addItem() {
        configuration = CreateOperation(withParentContext: viewContext)
    }
}
struct ItemEditor: View {
    @Environment(\.dismiss) private var dismiss
    @Environment(\.managedObjectContext) private var context
    
    @ObservedObject var item: Item
    var body: some View {
        NavigationView {
            Form {
                TextField("Name", text: Binding($item.name)!)
            }
            .navigationTitle("New Item")
            .toolbar {
                Button("Save") {
                    try? context.save()
                    dismiss()
                }
            }
        }
    }
}
Related