Search code examples
iosswiftcore-dataswiftui

How to pass a CoreData model item into a view for editing


I have a minimal sample project at CDPassingQ

My main (ContentView) looks like:

import SwiftUI
import CoreData

struct ContentView: View {
    @Environment( \.managedObjectContext ) private var viewContext
    
    @FetchRequest( sortDescriptors: [ NSSortDescriptor( keyPath: \Item.name, ascending: true ) ],
                   animation:       .default )
    private var items: FetchedResults<Item>
    
    var body: some View {
        NavigationView {
            List {
                ForEach( items ) { item in
                    NavigationLink {
                        NameViewer( itemID: item.objectID )
                    } label: {
                        Text( item.name! )
                    }
                }
                .onDelete( perform: deleteItems )
            }
            .toolbar {
                ToolbarItem( placement: .navigationBarTrailing ) {
                    EditButton()
                }
                
                ToolbarItem {
                    Button() {
                        print( "Add Item" )
                    } label: {
                        NavigationLink {
                            NameViewer();
                        } label: {
                            Label( "Add Item", systemImage: "plus" )
                        }
                    }
                }
            }
        }
    }
    
    
    
    private func deleteItems(offsets: IndexSet) {
        withAnimation {
            offsets.map { items[$0] }.forEach(viewContext.delete)
            
            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)")
            }
        }
    }
}


struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
    }
}

and NameViewer looks like:

import SwiftUI
import CoreData

enum TrustReason: String, Identifiable, CaseIterable
{
    var id: UUID
    {
        return UUID();
    }
    
    case unknown         = "Unknown";
    case legalOnly       = "Legal Only";
    case goodLabeling    = "Good Labeling";
    case facilityClean   = "Facility Clean";
    case detailedAnswers = "Detailed Answers";
    case unresponsive    = "Unresponsive";
}



extension TrustReason
{
    var title: String
    {
        switch self
        {
            case .unknown:
                return "Unknown";
                
            case .legalOnly:
                return "Legal Only";
                
            case .goodLabeling:
                return "Good Labeling";
                
            case .facilityClean:
                return "Facility Clean";
                
            case .detailedAnswers:
                return "Detailed Answers";
                
            case .unresponsive:
                return "Unresponsive";
        }
    }
}



struct NameViewer: View {
    @Environment( \.presentationMode )     var         presentationMode
    @Environment( \.managedObjectContext ) private var moc
    
    @State private var name: String = ""
    @State private var reason: TrustReason = .unknown

    var itemID: NSManagedObjectID?
    
    var body: some View {
        Form {
            Section( header: Text( "Information" ) ) {
                TextField( "Name", text: $name )
            }
            
            Section( header: Text( "Trust" ) ) {
                Picker( "Reason", selection: $reason ) {
                    ForEach( TrustReason.allCases ) { trustReason in
                        Text( trustReason.title ).tag( trustReason )
                    }
                }
            }
        }
        .toolbar {
            Button() {
                if ( saveName() ) {
                    self.presentationMode.wrappedValue.dismiss()
                }
            } label: {
                Text( "Save" )
            }
        }
        .onAppear {
            print( "on appear" )
            
            guard let theID = itemID,
                  let item = moc.object( with: theID ) as? Item else {
                      return
                  }
            
            print( "passed guard" )
            
            if let itemName = item.name {
                name = itemName
            }
            
            print( name )
        }
    }
    
    
    
    private func saveName() -> Bool {
        let item = Item( context: moc )
        
        do {
            print( self.name )
            
            item.name = self.name
            
            try moc.save()
            
            return true
        } catch {
            print( error )
            print( error.localizedDescription )
        }
        
        self.moc.rollback();
        
        return false
    }
}



struct NameViewer_Previews: PreviewProvider {
    static var previews: some View {
        NameViewer()
    }
}

I can create new items to be displayed in the list in ContentView.

Then, when I select an item in the list, I am passing that item to NameViewer. I can confirm that I am successfully finding the correct object in the .onAppear code.

However, there are two problems:

  1. If I select an item in the list, the item name does not appear in the Name TextField unless I click in the text field first.'

  2. Using .onAppear does not seem to be the right place to put that code. The reason is the Picker pushes another view onto the stack and once the item is picked, .onAppear runs again and I lose changes name to the name field.

How can I change the code to resolve these issues?


Solution

  • To implement the desired functionality, I would alter your architecture both on the UI and Core Data sides.

    In terms of the user interface, it is best to use navigation links for displaying static data detail views and use modals to carry out data operations, such as creating and editing objects. So have one view to display object detail (e.g. NameViewer) and another to edit objects (e.g. NameEditor). Also, bind properties of your NSManagedObject subclasses directly to SwiftUI controls. Don’t create extra @State properties and then copy over the values. You’re introducing a shared state, something that SwiftUI is there to eliminate.

    On the Core Data side, in order to perform create and update operations, you need to use child contexts. Any time you’re creating or updating your objects show a modal editor view with child context injected. That way if we’re unhappy with our changes, we can simply dismiss that modal and changes are magically discarded without ever needing to call rollback(), since that child context gets destroyed with the view. Since you’re now using child contexts, don’t forget to save your main view context somewhere too, like when the user navigates out of your app.

    So to implement that in code, we need some structs to store our newly created objects as well as child contexts for them:

    struct CreateOperation<Object: NSManagedObject>: Identifiable {
        let id = UUID()
        let childContext: NSManagedObjectContext
        let childObject: Object
        
        init(with parentContext: NSManagedObjectContext) {
            let childContext = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType)
            childContext.parent = parentContext
            let childObject = Object(context: childContext)
            
            self.childContext = childContext
            self.childObject = childObject
        }
    }
    
    struct UpdateOperation<Object: NSManagedObject>: Identifiable {
        let id = UUID()
        let childContext: NSManagedObjectContext
        let childObject: Object
        
        init?(
            withExistingObject object: Object,
            in parentContext: NSManagedObjectContext
        ) {
            let childContext = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType)
            childContext.parent = parentContext
            guard let childObject = try? childContext.existingObject(with: object.objectID) as? Object else { return nil }
            
            self.childContext = childContext
            self.childObject = childObject
        }
    }
    

    And the UI code is as follows:

    struct ContentView: View {
        @Environment(\.managedObjectContext) private var viewContext
        @FetchRequest(
            sortDescriptors: [NSSortDescriptor(keyPath: \Item.name, ascending: true)], animation: .default
        ) private var items: FetchedResults<Item>
        @State private var itemCreateOperation: CreateOperation<Item>?
        
        var body: some View {
            NavigationView {
                List {
                    ForEach(items) { item in
                        NavigationLink {
                            NameViewer(item: item)
                        } label: {
                            Text(item.name ?? "")
                        }
                    }
                }
                .toolbar {
                    ToolbarItemGroup(placement: .navigationBarTrailing) {
                        EditButton()
                        Button(action: {
                            itemCreateOperation = CreateOperation(with: viewContext)
                        }) {
                            Label("Add Item", systemImage: "plus")
                        }
                    }
                }
                .sheet(item: $itemCreateOperation) { createOperation in
                    NavigationView {
                        NameEditor(item: createOperation.childObject)
                            .navigationTitle("New Item")
                    }
                    .environment(\.managedObjectContext, createOperation.childContext)
                }
            }
        }
    }
    
    struct NameViewer: View {
        @Environment(\.managedObjectContext) private var viewContext
        @State private var itemUpdateOperation: UpdateOperation<Item>?
        
        @ObservedObject var item: Item
        
        var body: some View {
            Form {
                Section {
                    Text(item.name ?? "")
                }
            }
            .navigationTitle("Item")
            .toolbar  {
                Button("Update") {
                    itemUpdateOperation = UpdateOperation(withExistingObject: item, in: viewContext)
                }
            }
            .sheet(item: $itemUpdateOperation) { updateOperation in
                NavigationView {
                    NameEditor(item: updateOperation.childObject)
                        .navigationTitle("Update Item")
                }
                .environment(\.managedObjectContext, updateOperation.childContext)
            }
        }
    }
    
    struct NameEditor: View {
        @Environment(\.dismiss) private var dismiss
        @Environment(\.managedObjectContext) private var childContext
        
        @ObservedObject var item: Item
        
        var body: some View {
            Form {
                Section(header: Text("Information")) {
                    if let name = Binding($item.name) {
                        TextField("Name", text: name)
                    }
                }
            }
            .toolbar {
                Button() {
                    try? childContext.save()
                    dismiss()
                } label: {
                    Text("Save")
                }
            }
        }
    }
    

    For more information, see my related answers: