Search code examples
core-dataswiftuipicker

How to use a picker on CoreData relationships in SwiftUI


G'day everyone,

I'm trying to work out how CoreData relationships can work with UI elements like pickers.

At the moment I have a 3 view app (based on the Xcode boilerplate code) which displays a list of parent entities, which have children which have children. I want a picker to select which grandchild a child entity should refer to.

At the moment I have two funny side effects:

  1. When I run the app as a preview (so there is pre-populated data... this sample code will break without the data in place),
  • the selected grandchild in the picker is the grandchild of the first child, irrespective of which child you're dropped into in the first view.
  • When I drop back and pick another child, now the picked grabs the correct initial selection from the child entity
  1. When I select a child and "save" that, the value in the child summary does not change, until I click another child at which point the value changes before the transition to the modal view.

I am clearly missing something in my understanding of the sequence of events when presenting modals in SwiftUI... can any what shed any light on what I've done wrong?

Here's a video to make this more clear: https://github.com/andrewjdavison/Test31/blob/main/Test31%20-%20first%20click%20issue.mov?raw=true

Git repository of the sample is https://github.com/andrewjdavison/Test31.git, but in summary:

Data Model: Data Model

View Source:

import SwiftUI
import CoreData


struct LicenceView : View {
    @Environment(\.managedObjectContext) private var viewContext
    @Binding var licence: Licence
    @Binding var showModal: Bool
    
    @State var selectedElement: Element
    @FetchRequest private var elements: FetchedResults<Element>
    
    init(currentLicence: Binding<Licence>, showModal: Binding<Bool>, context: NSManagedObjectContext) {
        self._licence = currentLicence
        self._showModal = showModal
        
        let fetchRequest: NSFetchRequest<Element> = Element.fetchRequest()
        fetchRequest.sortDescriptors = []
        self._elements = FetchRequest(fetchRequest: fetchRequest)
        
        _selectedElement = State(initialValue: currentLicence.wrappedValue.licenced!)
    }
        
    func save() {
        licence.licenced = selectedElement
        try! viewContext.save()
        showModal = false
        
    }
    
    var body: some View {
        VStack {
            Button(action: {showModal = false}) {
                Text("Close")
            }
            Picker(selection: $selectedElement, label: Text("Element")) {
                ForEach(elements, id: \.self) { element in
                    Text("\(element.desc!)")
                }
            }
            Text("Selected: \(selectedElement.desc!)")
            Button(action: {save()}) {
                Text("Save")
            }
        }
        
    }
}

struct RegisterView : View {
    @Environment(\.managedObjectContext) private var viewContext
    
    @State var showModal: Bool = false
    var currentRegister: Register
    
    @State var currentLicence: Licence
    
    init(currentRegister: Register) {
        currentLicence = Array(currentRegister.licencedUsers! as! Set<Licence>)[0]
        self.currentRegister = currentRegister
    }
    
    var body: some View {
        VStack {
            List {
                ForEach (Array(currentRegister.licencedUsers! as! Set<Licence>), id: \.self) { licence in
                    Button(action: {currentLicence = licence; showModal = true}) {
                        HStack {
                            Text("\(licence.leasee!) : ")
                            Text("\(licence.licenced!.desc!)")
                        }
                    }
                }
            }
        }
        .sheet(isPresented: $showModal) {
            LicenceView(currentLicence: $currentLicence, showModal: $showModal, context: viewContext )
        }
    }
}


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

    @FetchRequest(
        sortDescriptors: [NSSortDescriptor(keyPath: \Register.id, ascending: true)],
        animation: .default)
    private var registers: FetchedResults<Register>

    var body: some View {
        NavigationView {
            List {
                ForEach(registers) { register in
                    NavigationLink(destination: RegisterView(currentRegister: register)) {
                        Text("Register id \(register.id!)")
                    }
                }
            }
        }
    }
}

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


[1]: https://i.sstatic.net/AfaNb.png

Solution

  • OK. Huge vote of thanks to Lorem for getting me to the answer. Thanks too for Roma, but it does turn out that his solution, whilst it worked to resolve one of my key problems, does introduce inefficiencies - and didn't resolve the second one.

    If others are hitting the same issue I'll leave the Github repo up, but the crux of it all was that @State shouldn't be used when you're sharing CoreData objects around. @ObservedObject is the way to go here.

    So the resolution to the problems I encountered were:

    1. Use @ObservedObject instead of @State for passing around the CoreData objects
    2. Make sure that the picker has a tag defined. The documentation I head read implied that this gets generated automatically if you use ".self" as the id for the objects in ForEach, but it seems this is not always reliable. so adding ".tag(element as Element?)" to my picker helped here. Note: It needed to be an optional type because CoreData makes all the attribute types optional.

    Those two alone fixed the problems.

    The revised "LicenceView" struct is here, but the whole solution is in the repo.

    Cheers!

    struct LicenceView : View {
        @Environment(\.managedObjectContext) private var viewContext
        @ObservedObject var licence: Licence
        @Binding var showModal: Bool
        
        @FetchRequest(
            sortDescriptors: [NSSortDescriptor(keyPath: \Element.desc, ascending: true)],
            animation: .default)
        private var elements: FetchedResults<Element>
        
        func save() {
            try! viewContext.save()
            showModal = false
        }
        
        var body: some View {
            VStack {
                Button(action: {showModal = false}) {
                    Text("Close")
                }
                Picker(selection: $licence.licenced, label: Text("Element")) {
                    ForEach(elements, id: \.self) { element in
                        Text("\(element.desc!)")
                            .tag(element as Element?)
                    }
                }
                Text("Selected: \(licence.licenced!.desc!)")
                Button(action: {save()}) {
                    Text("Save")
                }
            }
            
        }
    }