SwiftUI picker separate texts for selected item and selection view

Viewed 2440

I have a Picker embedded in a Form inside a NavigationView. I'd like to have a separate text for the chosen item in the main View and a more detailed descriptions when choosing items in the picker View.

This is what I tried so far:

struct Item {
    let abbr: String
    let desc: String
}
struct ContentView: View {
    @State private var selectedIndex = 0
    let items: [Item] = [
        Item(abbr: "AA", desc: "aaaaa"),
        Item(abbr: "BB", desc: "bbbbb"),
        Item(abbr: "CC", desc: "ccccc"),
    ]

    var body: some View {
        NavigationView {
            Form {
                picker
            }
        }
    }

    var picker: some View {
        Picker(selection: $selectedIndex, label: Text("Chosen item")) {
            ForEach(0..<items.count) { index in
                Group {
                    if self.selectedIndex == index {
                        Text(self.items[index].abbr)
                    } else {
                        Text(self.items[index].desc)
                    }
                }
                .tag(index)
            }
            .id(UUID())
        }
    }
}

Current solution

This is the picker in the main view:

Chosen item

And this is the selection view:

Picker selection

The problem is that with this solution in the selection view there is "BB" instead of "bbbbb".

This occurs because the "BB" text in both screens is produced by the very same Text view.

Expected result

The picker in the main view:

Chosen item

And in the selection view:

Expected result

Is it possible in SwiftUI to have separate texts (views) for both screens?

3 Answers

Possible solution without a Picker

As mention in my comment, there is not yet a solution for a native implementation with the SwiftUI Picker. Instead, you can do it with SwiftUI Elements especially with a NavigationLink. Here is a sample code:

struct Item {
    let abbr: String
    let desc: String
}

struct ContentView: View {
    
    @State private var selectedIndex = 0
    let items: [Item] = [
        Item(abbr: "AA", desc: "aaaaa"),
        Item(abbr: "BB", desc: "bbbbb"),
        Item(abbr: "CC", desc: "ccccc"),
    ]
    
    var body: some View {
        NavigationView {
            Form {
                NavigationLink(destination: (
                    DetailSelectionView(items: items, selectedItem: $selectedIndex)
                    ), label: {
                        HStack {
                            Text("Chosen item")
                            Spacer()
                            Text(self.items[selectedIndex].abbr).foregroundColor(Color.gray)
                        }
                })
            }
        }
    }
}

struct DetailSelectionView: View {
    var items: [Item]
    @Binding var selectedItem: Int
    
    var body: some View {
        Form {
            ForEach(0..<items.count) { index in
                HStack {
                    Text(self.items[index].desc)
                    Spacer()
                    if self.selectedItem == index {
                        Image(systemName: "checkmark").foregroundColor(Color.blue)
                    }
                }
                .onTapGesture {
                    self.selectedItem = index
                }
            }
        }
    }
}

If there are any improvements feel free to edit the code snippet.

Expanding on JonasDeichelmann's answer I created my own picker:

struct CustomPicker<Item>: View where Item: Hashable {
    @State var isLinkActive = false
    @Binding var selection: Int
    let title: String
    let items: [Item]
    let shortText: KeyPath<Item, String>
    let longText: KeyPath<Item, String>

    var body: some View {
        NavigationLink(destination: selectionView, isActive: $isLinkActive, label: {
            HStack {
                Text(title)
                Spacer()
                Text(items[selection][keyPath: shortText])
                    .foregroundColor(Color.gray)
            }
        })
    }

    var selectionView: some View {
        Form {
            ForEach(0 ..< items.count) { index in
                Button(action: {
                    self.selection = index
                    self.isLinkActive = false
                }) {
                    HStack {
                        Text(self.items[index][keyPath: self.longText])
                        Spacer()
                        if self.selection == index {
                            Image(systemName: "checkmark")
                                .foregroundColor(Color.blue)
                        }
                    }
                    .contentShape(Rectangle())
                    .foregroundColor(.primary)
                }
            }
        }
    }
}

Then we have to make Item conform to Hashable:

struct Item: Hashable { ... }

And we can use it like this:

struct ContentView: View {
    @State private var selectedIndex = 0
    let items: [Item] = [
        Item(abbr: "AA", desc: "aaaaa"),
        Item(abbr: "BB", desc: "bbbbb"),
        Item(abbr: "CC", desc: "ccccc"),
    ]

    var body: some View {
        NavigationView {
            Form {
                CustomPicker(selection: $selectedIndex, title: "Item", items: items,
                             shortText: \Item.abbr, longText: \Item.desc)
            }
        }
    }
}

Note: Currently the picker's layout cannot be changed. If needed it can be made more generic using eg. @ViewBuilder.

I've had another try at a custom split picker.

enter image description here


Implementation

  1. First, we need a struct as we'll use different items for selection, main screen and picker screen.
public struct PickerItem<
    Selection: Hashable & LosslessStringConvertible,
    Short: Hashable & LosslessStringConvertible,
    Long: Hashable & LosslessStringConvertible
>: Hashable {
    public let selection: Selection
    public let short: Short
    public let long: Long

    public init(selection: Selection, short: Short, long: Long) {
        self.selection = selection
        self.short = short
        self.long = long
    }
}
  1. Then, we create a custom view with an inner NavigationLink to simulate the behaviour of a Picker:
public struct SplitPicker<
    Label: View,
    Selection: Hashable & LosslessStringConvertible,
    ShortValue: Hashable & LosslessStringConvertible,
    LongValue: Hashable & LosslessStringConvertible
>: View {
    public typealias Item = PickerItem<Selection, ShortValue, LongValue>

    @State private var isLinkActive = false

    @Binding private var selection: Selection
    private let items: [Item]
    private var showMultiLabels: Bool
    private let label: () -> Label

    public init(
        selection: Binding<Selection>,
        items: [Item],
        showMultiLabels: Bool = false,
        label: @escaping () -> Label
    ) {
        self._selection = selection
        self.items = items
        self.showMultiLabels = showMultiLabels
        self.label = label
    }

    public var body: some View {
        NavigationLink(destination: selectionView, isActive: $isLinkActive) {
            HStack {
                label()
                Spacer()
                if let selectedItem = selectedItem {
                    Text(String(selectedItem.short))
                        .foregroundColor(Color.secondary)
                }
            }
        }
    }
}
private extension SplitPicker {
    var selectedItem: Item? {
        items.first { selection == $0.selection }
    }
}

private extension SplitPicker {
    var selectionView: some View {
        Form {
            ForEach(items, id: \.self) { item in
                itemView(item: item)
            }
        }
    }
}

private extension SplitPicker {
    func itemView(item: Item) -> some View {
        Button(action: {
            selection = item.selection
            isLinkActive = false
        }) {
            HStack {
                if showMultiLabels {
                    itemMultiLabelView(item: item)
                } else {
                    itemLabelView(item: item)
                }
                Spacer()
                if item == selectedItem {
                    Image(systemName: "checkmark")
                        .font(Font.body.weight(.semibold))
                        .foregroundColor(.accentColor)
                }
            }
            .contentShape(Rectangle())
        }
    }
}

private extension SplitPicker {
    func itemLabelView(item: Item) -> some View {
        HStack {
            Text(String(item.long))
                .foregroundColor(.primary)
            Spacer()
        }
    }
}

private extension SplitPicker {
    func itemMultiLabelView(item: Item) -> some View {
        HStack {
            HStack {
                Text(String(item.short))
                    .foregroundColor(.primary)
                Spacer()
            }
            .frame(maxWidth: 50)
            Text(String(item.long))
                .font(.subheadline)
                .foregroundColor(.secondary)
        }
    }
}

Demo

struct ContentView: View {
    @State private var selection = 2

    let items = (1...5)
        .map {
            PickerItem(
                selection: $0,
                short: String($0),
                long: "Long text of: \($0)"
            )
        }
    
    var body: some View {
        NavigationView {
            Form {
                Text("Selected index: \(selection)")
                SplitPicker(selection: $selection, items: items) {
                    Text("Split picker")
                }
            }
        }
    }
}
Related