How do you atomically set a Picker's
selection and contents on macOS such that you don't end up in a situation where the selection is not present within the Picker's content?
I presume Picker
on macOS is implemented as an NSPopUpButton
and an NSPopUpButton
doesn't really like the concept of "no selection". SwiftUI, when presented with that, outputs:
Picker: the selection "nil" is invalid and does not have an associated tag, this will give undefined results.
Consider the following pseudo code:
struct ParentView: View {
@State private var items: [Item]
var body: some View {
ChildView(items: items)
}
}
struct ChildView: View {
let items: [Item]
@State private var selectedItem: Item?
var body: some View {
Picker("", selection: $selectedItem) {
ForEach(items) { item in
Text(item.name).tag(item)
}
}
}
}
When items
gets passed down from ParentView
to the ChildView
, it's entirely possible that the current value in selectedItem
represents an Item
that is not longer in the items[]
array.
You can "catch" that by using .onAppear, .task, .onChange
and maybe some other modifiers, but not until after at least one render pass has happened and an error has likely been reported because selectedItem
is nil or it's not represented in the items[]
array.
Because selectedItem
is private state, a value can't easily be passed down from the parent view, though even if it could that just kind of moves the problem one level higher up.
What is the correct way to handle this type of data flow in SwiftUI for macOS?
Picker: the selection "nil" is invalid and does not have an associated tag, this will give undefined results.
on macOS, this seems to be specific to the default pickerStyle which is a menu
.
The error message hints at possible ways to handle this. You can provide an initial selection value and a default value if that item gets removed from the collection.
For example:
struct ChildView: View {
var items: [Item]
@State private var selectedItem: Item?
private var selection: Binding<Item?> {
Binding(
get: {
items.first(where: { $0 == selectedItem }) ?? items.first
},
set: { newItem in
selectedItem = newItem
}
)
}
var body: some View {
Picker("Test", selection: selection) {
ForEach(items) { item in
Text(item.name)
.tag(item, includeOptional: true)
}
}
}
}
Another option is to use a Menu instead If it’s multiple selection or if you want the default picker style.