Implementing the “one or more” UI component
I wanted to try this for years.
In Tog on Interface, Tog designs a picker for selecting one or more options, but not none.
Think languages or time zones: you need one; you can have more; you can’t have none.
We actually don’t have a dedicated component for this, even today. We use checkboxes for multiple selection, and disable continuing when none are selected.
But what if?
So Tog gets cracking.
It’s riveting. He makes prototypes that epic-fail usability testing, and ultimately finds this design which makes sense to users:
- Click to select or deselect.
- If deselecting would leave none selected, auto-select the next option.
- … but if there’s no next option, auto-select the previous option, and enable “go up” mode.
- Disable “go up” mode after auto-selecting the topmost option, or the user manually selects any option*.
It was inspired a drop of mercury, which pops sideways when you poke it. Otherwise, it’s regular checkboxes.
So we’re all dying to find out how this actually feels to use, right? What better to do this fine day than implement a fabled UI component? Are you not entertained?
Drum roll:
struct MercuryPicker: View {
var body: some View {
List(0 ..< picker.options.count, id: \.self) { index in
HStack {
Image(
systemName: picker.selected.contains(index)
? "diamond.fill"
: "diamond"
)
Text(picker.options[index])
Spacer()
}
.contentShape(Rectangle())
.onTapGesture {
if picker.selected.contains(index) {
picker.deselect(at: index)
} else {
picker.select(at: index)
}
}
}
}
@State private var picker = OneOrMore(
["English", "French", "Japanese", "Klingon", "Lojban"],
selected: [0])!
}
struct OneOrMore {
init?(_ options: [String], selected: Set<Int>) {
guard !options.isEmpty, !selected.isEmpty else { return nil }
self.options = options; self.selected = selected
}
let options: [String]
private(set) var selected: Set<Int>
mutating func select(at index: Int) {
selected.insert(index)
// squishesUp = false // * Tog’s version includes this, but I find it clearer without it.
}
mutating func deselect(at index: Int) {
selected.remove(index)
guard selected.isEmpty else { return }
var toSelect: Int = squishesUp ? (index-1) : (index+1)
if !options.indices.contains(toSelect) {
squishesUp.toggle()
toSelect = squishesUp ? (index-1) : (index+1)
}
selected.insert(toSelect)
}
private var squishesUp = false
}
… and??
It’s fine.
The hidden state of “go up” mode is a little annoying. It also has hidden logic; so does the checkbox version, but that’s simpler.
I tested it on my parents. Both figured out the “one or more” rule, but only one figured out which way the mercury would pop every time.
The checkbox version is better overall because it’s easier to both learn and implement.
But that was fun!
Open questions
- What should we call this component? I like “multipicker”; shorter than “one-or-more picker”, and easier to explain than “mercury picker”.
- Can someone make a web version so everyone can play with it? How about you? [Update: that was quick.]
- Why does iOS not even have radio buttons?