1

I have a SwiftUI list, defined in a typical fashion:

struct SettingsView: View
{
    @State private var selectedCategory: SettingsCategory? = .general

    List(SettingsCategory.allCases, id: \.self, selection: $selectedCategory) { category in
        [...]
    }
}

In this case, the List is a table of "categories" for a settings area in my UI. The SettingsCategory is an enum that defines these categories, and the UI ends up looking like this:

enter image description here

It is not appropriate for this list to have an empty selection; a category should always be selected. In AppKit, it was trivially easy to disable an empty selection on NSTableView. But in SwiftUI, I've been unable to find a way to disable it. Anytime I click in the empty area of the list, the selection is cleared. How can I stop that?

selectedCategory must be an Optional or the compiler vomits all over itself.

I can't use willSet/didSet on selectedCategory because of the @State property wrapper. And I can't use a computed property that never returns nil because the List's selection has to be bound.

I also tried this approach: SwiftUI DatePicker Binding optional Date, valid nil

So, what magical incantation is required to disable empty selection in List?

Bryan
  • 4,628
  • 3
  • 36
  • 62
  • I am not sure this is the way you want to go. In order for `selection` on a `List` to work, you have to have the `List` in `editMode`. Once the list is in `editMode` the initial selection is selected. You can roll your own implementation with List pretty easily, just don't use `selection`. It isn't meant to be for menus. – Yrb Feb 14 '22 at 02:01
  • @Yrb that limitation is for the toy platforms: iOS and tvOS. On macOS, no such limitation exists. – Bryan Feb 14 '22 at 02:10
  • Regardless of whether it is on the "toy" platforms or not, `selection` was not intended to be a menu picker. In the time it took you to ask this question, you could have rolled your own solution. But, keep trying to make this work, by all means. – Yrb Feb 14 '22 at 02:12
  • @yrb I’d love to see your proposed solution. Using a `NSTableView` to power a UI like this has been standard practice on the Mac for quite literally decades. If there’s a better approach than `List`, please do share. – Bryan Feb 14 '22 at 02:33
  • `List` also comes with lots of freebies that would be tedious to implement manually: the correct selection color based on the `NSWindow` state (main/key, background), the automatic coloring of text/icon in selected rows, etc. And `List` selections are absolutely appropriate: lots of Mac UI changes based on selections in a `List`. Think of a simple master/detail UI with an inspector on the right that shows details about the stuff selected in the list. – Bryan Feb 14 '22 at 02:44

2 Answers2

3

One solution would be to set the selection back to the original one if the selection becomes nil.

Code:

struct ContentView: View {
    @State private var selectedCategory: SettingsCategory = .general

    var body: some View {
        NavigationView {
            SettingsView(selectedCategory: $selectedCategory)

            Text("Category: \(selectedCategory.rawValue.capitalized)")
                .navigationTitle("App")
        }
    }
}
enum SettingsCategory: String, CaseIterable, Identifiable {
    case destination
    case general
    case speed
    case schedule
    case advanced
    case scripts

    var id: String { rawValue }
}
struct SettingsView: View {
    @Binding private var selectedCategory: SettingsCategory
    @State private var selection: SettingsCategory?

    init(selectedCategory: Binding<SettingsCategory>) {
        _selectedCategory = Binding<SettingsCategory>(
            get: { selectedCategory.wrappedValue },
            set: { newCategory in
                selectedCategory.wrappedValue = newCategory
            }
        )
    }

    var body: some View {
        List(SettingsCategory.allCases, selection: $selection) { category in
            Text(category.rawValue.capitalized)
                .tag(category)
        }
        .onChange(of: selection) { [oldCategory = selection] newCategory in
            if let newCategory = newCategory {
                selection = newCategory
                selectedCategory = newCategory
            } else {
                selection = oldCategory
            }
        }
    }
}
George
  • 25,988
  • 10
  • 79
  • 133
2

you could try adding .onChange to the List, such as:

.onChange(of: selectedCategory) { val in
    if val == nil {
        selectedCategory = .general // <-- make sure never nil
    }
}
  • 1
    This works! It's a *little* kludgy because the selection "shudders" to empty and then back to selected, which entails extra view drawing, but this at least works. I still feel like the ultimate solution is a custom @State wrapper that disallows nil values in the setter, but this is at least something! – Bryan Feb 14 '22 at 05:41