Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Picker for optional data type in SwiftUI?

Tags:

swiftui

Normally I can display a list of items like this in SwiftUI:

enum Fruit {
    case apple
    case orange
    case banana
}

struct FruitView: View {

    @State private var fruit = Fruit.apple

    var body: some View {
        Picker(selection: $fruit, label: Text("Fruit")) {
            ForEach(Fruit.allCases) { fruit in
                Text(fruit.rawValue).tag(fruit)
            }
        }
    }
}

This works perfectly, allowing me to select whichever fruit I want. If I want to switch fruit to be nullable (aka an optional), though, it causes problems:

struct FruitView: View {

    @State private var fruit: Fruit?

    var body: some View {
        Picker(selection: $fruit, label: Text("Fruit")) {
            ForEach(Fruit.allCases) { fruit in
                Text(fruit.rawValue).tag(fruit)
            }
        }
    }
}

The selected fruit name is no longer displayed on the first screen, and no matter what selection item I choose, it doesn't update the fruit value.

How do I use Picker with an optional type?

like image 843
Senseful Avatar asked Dec 15 '19 21:12

Senseful


3 Answers

The tag must match the exact data type as the binding is wrapping. In this case the data type provided to tag is Fruit but the data type of $fruit.wrappedValue is Fruit?. You can fix this by casting the datatype in the tag method:

struct FruitView: View {

    @State private var fruit: Fruit?

    var body: some View {
        Picker(selection: $fruit, label: Text("Fruit")) {
            ForEach(Fruit.allCases) { fruit in
                Text(fruit.rawValue).tag(fruit as Fruit?)
            }
        }
    }
}

Bonus: If you want custom text for nil (instead of just blank), and want the user to be allowed to select nil (Note: it's either all or nothing here), you can include an item for nil:

struct FruitView: View {

    @State private var fruit: Fruit?

    var body: some View {
        Picker(selection: $fruit, label: Text("Fruit")) {
            Text("No fruit").tag(nil as Fruit?)
            ForEach(Fruit.allCases) { fruit in
                Text(fruit.rawValue).tag(fruit as Fruit?)
            }
        }
    }
}

Don't forget to cast the nil value as well.

like image 53
Senseful Avatar answered Oct 21 '22 08:10

Senseful


I made a public repo here with Senseful's solution: https://github.com/andrewthedina/SwiftUIPickerWithOptionalSelection

EDIT: Thank you for the comments regarding posting links. Here is the code which answers the question. Copy/paste will do the trick, or clone the repo from the link.

import SwiftUI

struct ContentView: View {
    @State private var selectionOne: String? = nil
    @State private var selectionTwo: String? = nil
    
    let items = ["Item A", "Item B", "Item C"]
    
    var body: some View {
        NavigationView {
            Form {
                // MARK: - Option 1: NIL by SELECTION
                Picker(selection: $selectionOne, label: Text("Picker with option to select nil item [none]")) {
                    Text("[none]").tag(nil as String?)
                        .foregroundColor(.red)

                    ForEach(items, id: \.self) { item in
                        Text(item).tag(item as String?)
                        // Tags must be cast to same type as Picker selection
                    }
                }
                
                // MARK: - Option 2: NIL by BUTTON ACTION
                Picker(selection: $selectionTwo, label: Text("Picker with Button that removes selection")) {
                    ForEach(items, id: \.self) { item in
                        Text(item).tag(item as String?)
                        // Tags must be cast to same type as Picker selection
                    }
                }
                
                if selectionTwo != nil { // "Remove item" button only appears if selection is not nil
                    Button("Remove item") {
                        self.selectionTwo = nil
                    }
                }
            }
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
like image 25
atdonsm Avatar answered Oct 21 '22 09:10

atdonsm


I actually prefer @Senseful's solution for a point solution, but for posterity: you could also create a wrapper enum, which if you have a ton of entity types in your app scales quite nicely via protocol extensions.

// utility constraint to ensure a default id can be produced
protocol EmptyInitializable {
    init()
}

// primary constraint on PickerValue wrapper
protocol Pickable {
    associatedtype Element: Identifiable where Element.ID: EmptyInitializable
}

// wrapper to hide optionality
enum PickerValue<Element>: Pickable where Element: Identifiable, Element.ID: EmptyInitializable {
    case none
    case some(Element)
}

// hashable & equtable on the wrapper
extension PickerValue: Hashable & Equatable {
    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }
    
    static func ==(lhs: Self, rhs: Self) -> Bool {
        lhs.id == rhs.id
    }
}

// common identifiable types
extension String: EmptyInitializable {}
extension Int: EmptyInitializable {}
extension UInt: EmptyInitializable {}
extension UInt8: EmptyInitializable {}
extension UInt16: EmptyInitializable {}
extension UInt32: EmptyInitializable {}
extension UInt64: EmptyInitializable {}
extension UUID: EmptyInitializable {}

// id producer on wrapper
extension PickerValue: Identifiable {
    var id: Element.ID {
        switch self {
            case .some(let e):
                return e.id
            case .none:
                return Element.ID()
        }
    }
}

// utility extensions on Array to wrap into PickerValues
extension Array where Element: Identifiable, Element.ID: EmptyInitializable {
    var pickable: Array<PickerValue<Element>> {
        map { .some($0) }
    }
    
    var optionalPickable: Array<PickerValue<Element>> {
        [.none] + pickable
    }
}

// benefit of wrapping with protocols is that item views can be common
// across data sets.  (Here TitleComponent { var title: String { get }})
extension PickerValue where Element: TitleComponent {
    @ViewBuilder
    var itemView: some View {
        Group {
            switch self {
                case .some(let e):
                    Text(e.title)
                case .none:
                    Text("None")
                        .italic()
                        .foregroundColor(.accentColor)
            }
        }
        .tag(self)
    }
}

Usage is then quite tight:

Picker(selection: $task.job, label: Text("Job")) {
    ForEach(Model.shared.jobs.optionalPickable) { p in
        p.itemView
    }
}
like image 2
Stan Avatar answered Oct 21 '22 08:10

Stan