TEAM LICENSES: Save money and learn new skills through a Hacking with Swift+ team license >>

Is it possible to create a Picker like this? (SwiftData related)

Forums > SwiftUI

TLDR: My question is at the bottom of all of this code. But I wanted people to be able to understand my question fully, and recreate the project easily if they want to.

Lets say I want create an app that allows a user to be able to create Bicycle objects from different components (Frame, Wheels, Tires), so I create a data model like this...

import Foundation
import SwiftData

@Model
class Bicycle {
    var name: String = ""
    var frame: Frame?
    var wheels: Wheels?
    var tires: Tires?

    init(frame: Frame? = nil, wheels: Wheels? = nil, tires: Tires? = nil) {
        self.frame = frame
        self.wheels = wheels
        self.tires = tires
    }
}

@Model
class Frame {
    var make: String
    var model: String
    var bicycles: [Bicycle] = []

    init(make: String, model: String) {
        self.make = make
        self.model = model
    }
}

@Model
class Wheels {
    var make: String
    var diameter: Double
    var bicycles: [Bicycle] = []

    init(make: String, diameter: Double) {
        self.make = make
        self.diameter = diameter
    }
}

@Model
class Tires {
    var make: String
    var diameter: Double
    var bicycles: [Bicycle] = []

    init(make: String, diameter: Double) {
        self.make = make
        self.diameter = diameter
    }
}

Now, I want the user to see a list of all of the Bicycle that they have created, and be able to push a button to add a new Bicycle, which will take them to another view where they can select the components for their new Bicycle. So, we start with this view...

ContentView.swift

    import SwiftData
    import SwiftUI

    struct ContentView: View {
        @Environment(\.modelContext) var modelContext
        @Environment(\.dismiss) var dismiss

        @Query(sort:[SortDescriptor(\Bicycle.name)]) var bicycles: [Bicycle]

        @State private var addingBicycle = false

        var body: some View {
            NavigationStack() {
                List {
                    ForEach(bicycles) { bicycle in
                        Text(bicycle.name)
                    }
                    .onDelete(perform: deleteBicycles)
                }
                .navigationTitle("Bicycles")
                .navigationDestination(isPresented: $addingBicycle) {
                    AddBicycleView()
                }
                .toolbar {
                    Button("Add Bicycle", systemImage: "plus", action: addBicycle)
                }
            }
        }

        func addBicycle() {
            addingBicycle.toggle()
        }

        func deleteBicycles(_ indexSet: IndexSet) {
            for index in indexSet {
                let deletableBicycle = bicycles[index]
                modelContext.delete(deletableBicycle)
            }
        }
    }

    struct ContentView_Previews: PreviewProvider {
        static var previews: some View {
            ContentView()
        }
    }

And when the add button is tapped, we are taken to this view. So far so good.

import SwiftData
import SwiftUI

struct AddBicycleView: View {
    @Environment(\.modelContext) var modelContext
    @Environment(\.dismiss) var dismiss

    //Queries to get a list of each type of component, to be used in the Pickers
    @Query(sort: [SortDescriptor(\Frame.make, comparator: .localizedStandard), SortDescriptor(\Frame.model, comparator: .localizedStandard)]) var frames: [Frame]

    @Query(sort: [SortDescriptor(\Wheels.make, comparator: .localizedStandard), SortDescriptor(\Wheels.diameter)]) var wheels: [Wheels]

    @Query(sort: [SortDescriptor(\Tires.make, comparator: .localizedStandard), SortDescriptor(\Tires.diameter)]) var tires: [Tires]

    //Variables to store the values entered by the user
    @State private var enteredName = ""
    @State private var selectedFrame: Frame?
    @State private var selectedWheels: Wheels?
    @State private var selectedTires: Tires?

    var body: some View {
        Form {
            TextField("Enter a name", text: $enteredName)

            Picker("Frame", selection: $selectedFrame) {
                ForEach(frames) { frame in
                    Text("\(frame.make) - \(frame.model)")
                }
            }

            Picker("Wheels", selection: $selectedWheels) {
                ForEach(wheels) { wheel in
                    Text("\(wheel.make) - \(wheel.diameter)")
                }
            }

            Picker("Tires", selection: $selectedTires) {
                ForEach(tires) { tire in
                    Text("\(tire.make) - \(tire.diameter)")
                }
            }
        }
        .navigationTitle("Add Bicycle")
        .toolbar {
            Button("Add") {
                let newBicycle = Bicycle(name: enteredName, frame: selectedFrame, wheels: selectedWheels, tires: selectedTires)
                modelContext.insert(newBicycle)
                dismiss()
            }
        }
    }
}

//You can ignore this part if you want to, but this will make preview show some example data.
#Preview {
    do {
        let config = ModelConfiguration(isStoredInMemoryOnly: true)
        let container = try ModelContainer(for: Bicycle.self, configurations: config)

        let exampleFrame = Frame(make: "WorstBrand", model: "A")
        let exampleWheels = Wheels(make: "WorstBrand", diameter: 10.0)
        let exampleTires = Tires(make: "WorstBrand", diameter: 20.0)

        container.mainContext.insert(exampleFrame)
        container.mainContext.insert(exampleWheels)
        container.mainContext.insert(exampleTires)

        return AddBicycleView()
            .modelContainer(container)
    } catch {
        fatalError("Failed to create preview model container.")
    }
}

Don't forget to add the modelContainer to your WindowGroup if you are recreating this project yourself...

struct BicycleBuilderApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: Bicycle.self)
    }
}

But here's where the twist comes. What if I want the user to be able to select any of the parts that already exist in my database from the pickers, but also have an additional option "Add New" available in each of the Pickers. When the user selects the "Add New" option, I would want a sheet to be presented, allowing the user to fill in info for, and add a new Frame, Wheels, or Tires object, and have that show up as their selection from the picker. Is that possible?

I have tried this...

Picker("Frame", selection: $selectedFrame) {
    ForEach(frames) { frame in
        Text("\(frame.make) - \(frame.model)")
    }

    Text("Add New")
        .tag(nil as Frame?)
}

But I get a bunch of warnings in the console so I don't know if this is a valid solution that I should use.

"Picker: the selection "nil" is invalid and does not have an associated tag, this will give undefined results."

3      

hi @Fly,

my first thought was to put a Button("Add New") as a picker option, but tapping that will not execute the code.

but what you can do is just tag the Text("Add New") with nil as you've done, but also use an .onChange(of:) modifier to pick up any selection of this picker option: when nil appears as the new selection, open a sheet to add a new Tire, Frame, or Wheel object, and upon return, have the sheet execute a function to handle the intent to add a new object.

this seems to do what you want.

import SwiftUI

struct ContentView: View {

    @State private var choices = ["A", "B", "C"]
    @State private var selectedChoice: String? = "A"
    @State private var savedChoice: String?
    @State private var isShowNewChoiceSheetPresented = false

    var body: some View {
        HStack {
            Text("Available Tires:").bold()
            Spacer()
            Picker("", selection: $selectedChoice) {
                ForEach(choices, id: \.self) { (choice: String?) in
                    Text(choice!).tag(choice)
                }
                Text("Add New Value ...").tag(nil as String?)
            }
        }
        .padding()
        .onChange(of: selectedChoice, { oldValue, newValue in
            if newValue == nil { // you chose the Add New option
                savedChoice = oldValue // save in case we need to restore it
                isShowNewChoiceSheetPresented = true
            }
        })
        .sheet(isPresented: $isShowNewChoiceSheetPresented) {
            AddNewChoiceSheet(actionUponDismiss: handleDismissAction)
        }
    }

    func handleDismissAction(newChoice: String?) {
        if let newChoice {  // affirmative: we added a new choice, so select it
            choices.append(newChoice)
            selectedChoice = newChoice
        } else { // negative: we canceled and must restore the old choice
            selectedChoice = savedChoice
        }
    }
}

struct AddNewChoiceSheet: View {

    @Environment(\.dismiss) private var dismiss
    // the action to take when dismissing allows a nil return
    // to indicate a cancel operation
    var actionUponDismiss: (String?) -> Void
    var body: some View {
        VStack(spacing: 50) {
            Button("Add and Select D") {
                actionUponDismiss("D")
                dismiss()
            }
            Button("Dismiss with no choice") {
                actionUponDismiss(nil)
                dismiss()
            }
        }
    }
}

hope that helps,

DMG

5      

Yeah, this is kind of the direction I was thinking of going to get the other views to show. But the problem I still have is with having nil values for the picker selection. I'm thinking that I might be able to do something like this... but haven't had the time to try to get it fully working yet.

Picker("Frame", selection: $selectedFrame) {
    ForEach(frames) { frame in
        Text("\(frame.make) - \(frame.model)")
    }

    Text("Add New")
        .tag(Frame() as Frame?)
}

So, it would basically create a new Frame object, and then I might be able to use the method that Paul uses in the complete project tutorial from the "SwiftData by Example" book. In that project, we basically make the new object first, insert it into the modelContext, and then use that object to initialize the view that pops up and allows for the new object to be edited.

3      

hi @Fly,

one underlying assumption i made in my example was that, in practice, the selection would never really be nil. attaching a nil tag to the "Add New" selection has the side effect of setting the selection to nil; that's why you see a little logic to make sure that upon return from the Add New screen, i either set the selection to the new value, or restore the previous value.

although i used string data, it should be easy to use the same process with SwiftData objects.

  • the ContentView would have a @Query for the data you display (the choices variable).
  • you'd set the value of selectedChoice with an onAppear modifier so that the selection is not nil (i guess i am assuming that you always have at lease one possible choice to begin with, although having a nil when there are not yet any choices available should pretty much work).
  • when the AddNewChoiceSheet opens, you could use that opportunity (probably with an onAppear or via an init()) to create a new SwiftData object but not insert it into the modelContext, fill in its detail wthin the view, and return it in the actionUponDismiss function (return nil if you cancel the add new operation; i assume that any model object you created but did not insert into the model context would simply go out of scope and go away).
  • ContentView can then insert the return object into the modelContext, which should kick the @Query and update the ContentView.

hope that helps,

DMG

3      

Hacking with Swift is sponsored by Blaze.

SPONSORED Still waiting on your CI build? Speed it up ~3x with Blaze - change one line, pay less, keep your existing GitHub workflows. First 25 HWS readers to use code HACKING at checkout get 50% off the first year. Try it now for free!

Reserve your spot now

Sponsor Hacking with Swift and reach the world's largest Swift community!

Reply to this topic…

You need to create an account or log in to reply.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.