Advanced SwiftUI: Implementing a custom Picker
Explore advanced SwiftUI techniques by implementing a custom Picker. While SwiftUI’s default system picker is powerful, developers often desire more flexibility in design and behavior.
This article assumes familiarity with SwiftUI’s core principles and dives into creating a custom value picker for tailored aesthetics and enhanced control.
💭 Outlining the API
When creating components, I always initiate the process by outlining the intended API. This foundational step is crucial, as it prompts thoughtful consideration of the necessary actions to attain the desired API.
In this case, simplicity is key; we aim for an API closely resembling the system’s Picker API:
Picker("Flavor", selection: $selectedFlavor) {
ForEach(Flavor.allCases) { flavor in
Text(flavor.rawValue.capitalized)
}
}
Wouldn’t it be wonderful if we could replicate the same API for our custom ValuePicker
component? Let’s take a closer look and explore the possibilities.
ValuePicker("Label", selection: $selection) {
ForEach(values) { value in
Text(value)
}
}
🔨 Defining the view and its initializer
public struct ValuePicker<SelectionValue: Hashable, Content: View>: View {
private let title: LocalizedStringKey
private let selection: Binding<SelectionValue>
private let content: Content
public init(
_ title: LocalizedStringKey,
selection: Binding<SelectionValue>,
@ViewBuilder content: () -> Content
) {
self.title = title
self.selection = selection
self.content = content()
}
}
The view has to be generic over two types: SelectionValue
, representing the type of the value we can select, and Content
, which will encompass the set of possible options.
At the call-site, this would result in the following:
ValuePicker("Name", selection: $name) {
ForEach(names) { name in
Text(name)
}
}
So far, everything is smooth and straightforward — no complications or issues to address. Next, let's implement the view's body.
🩻 The view's body
public var body: some View {
NavigationLink {
List {
content
}
.navigationTitle(label)
} label: {
VStack {
Text(title)
.font(.footnote.weight(.medium))
.foregroundStyle(.secondary)
Text(verbatim: String(describing: selection.wrappedValue))
}
}
}
Which, within a list, renders like this:
#Preview {
NavigationStack {
List {
ValuePicker("Name", selection: .constant("John")) {
ForEach(["John", "Jean", "Juan"]) { name in
Text(verbatim: name)
}
}
}
.navigationTitle("Custom Picker")
}
}
That’s pretty cool; with a small amount of code, we’re now able to push a list of options. However, two key features are missing: when we push the options, it’s not clear which value is currently selected, and we also can’t really change the selection, which is the main purpose of a picker component 😅.
There’s a problem, though; we get access to the options through a single view (content
). Since it’s constructed within a @ViewBuilder
closure, we lack the ability to access individual options.
🛟 _VariadicView to the rescue
Have you ever heard of the _VariadicView
? If you haven’t, I would suggest you read this fantastic article on the topic by Moving Parts.
In short, _VariadicView
is an undocumented view capable of destructuring another view to access its child views.
We’re going to leverage its power to add a checkmark next to the selected row and make the rows interactive so we can finally change the selection.
private struct ValuePickerOptions<Value: Hashable>: _VariadicView.MultiViewRoot {
private let selectedValue: Binding<Value>
init(selectedValue: Binding<Value>) {
self.selectedValue = selectedValue
}
@ViewBuilder
func body(children: _VariadicView.Children) -> some View {
Section {
ForEach(children) { child in
ValuePickerOption(selectedValue: selectedValue) {
child
}
}
}
}
}
private struct ValuePickerOption<Content: View, Value: Hashable>: View {
@Environment(\.dismiss) private var dismiss
private let selectedValue: Binding<Value>
private let content: Content
init(
selectedValue: Binding<Value>,
@ViewBuilder _ content: () -> Content
) {
self.selectedValue = selectedValue
self.content = content()
}
var body: some View {
Button(
action: {
selectedValue.wrappedValue = ❓❓❓ // What value should we set here?
dismiss()
},
label: {
HStack {
content
.tint(.primary)
.frame(maxWidth: .infinity, alignment: .leading)
if isSelected {
Image(systemName: "checkmark")
.foregroundStyle(.tint)
.font(.body.weight(.semibold))
.accessibilityHidden(true)
}
}
.accessibilityElement(children: .combine)
.accessibilityAddTraits(isSelected ? .isSelected : [])
}
)
}
private var isSelected: Bool {
// How to determine whether that option is currently selected?
return ❓❓❓
}
}
Alright, that's quite a lot of code 😅
To summarize, ValuePickerOptions
takes a view and destructures it in its body. We take this opportunity to wrap every child into a ValuePickerOption
view (which itself wraps the child within a Button
) so we can customize the UI and behavior.
We now have another problem, how do we access the value associated with each option? 🤔
Seems like we’re stuck here.
How on earth is the system component doing it? Well, the documentation gives us the answer:
ForEach
automatically assigns a tag to the selection views using each option’sid
.
So it seems like Apple is injecting a tag into each view. We can indeed confirm this hypothesis by looking at SwiftUI’s headers:
extension View {
@inlinable public func tag<V>(_ tag: V) -> some View where V : Hashable {
return _trait(TagValueTraitKey<V>.self, .tagged(tag))
}
@inlinable public func _untagged() -> some View {
return _trait(IsAuxiliaryContentTraitKey.self, true)
}
}
@usableFromInline
internal struct TagValueTraitKey<V> : _ViewTraitKey where V : Hashable {
@usableFromInline
@frozen internal enum Value {
case untagged
case tagged(V)
}
@inlinable internal static var defaultValue: TagValueTraitKey<V>.Value {
get { .untagged }
}
}
@usableFromInline
internal struct IsAuxiliaryContentTraitKey : _ViewTraitKey {
@inlinable internal static var defaultValue: Bool {
get { false }
}
@usableFromInline
internal typealias Value = Bool
}
Unfortunately, TagValueTraitKey
is not publicly exposed, so we can't use it. Nevertheless, let's implement our own!
💉 Custom Tag Injection
private struct CustomTagValueTraitKey<V: Hashable>: _ViewTraitKey {
enum Value {
case untagged
case tagged(V)
}
static var defaultValue: CustomTagValueTraitKey<V>.Value {
.untagged
}
}
extension View {
public func pickerTag<V: Hashable>(_ tag: V) -> some View {
_trait(CustomTagValueTraitKey<V>.self, .tagged(tag))
}
}
With those being created, we can now inject a custom trait inside our ForEach
. We could also have created a special version of ForEach
to do it automatically, but I feel like the custom view extension is a good compromise:
NavigationStack {
List {
ValuePicker("Name", selection: .constant("John")) {
ForEach(["John", "Jean", "Juan"]) { name in
Text(verbatim: name)
.pickerTag(name) // 🟢🟢🟢
}
}
}
.navigationTitle("Custom Picker")
}
}
Each tag can be retrieved through the _VariadicView_Children.Element
using the subscript syntax. We'll pass it into the ValuePickerOption
view:
private struct ValuePickerOptions<Value: Hashable>: _VariadicView.MultiViewRoot {
...
@ViewBuilder
func body(children: _VariadicView.Children) -> some View {
Section {
ForEach(children) { child in
ValuePickerOption(
selectedValue: selectedValue,
value: child[CustomTagValueTraitKey<Value>.self] // 🟢🟢🟢
) {
child
}
}
}
}
}
private struct ValuePickerOption<Content: View, Value: Hashable>: View {
...
private let value: Value?
...
init(
selectedValue: Binding<Value>,
value: CustomTagValueTraitKey<Value>.Value, // 🟢🟢🟢
@ViewBuilder _ content: () -> Content
) {
self.selectedValue = selectedValue
self.value = if case .tagged(let tag) = value {
tag
} else {
nil
}
self.content = content()
}
var body: some View {
...
}
private var isSelected: Bool {
selectedValue.wrappedValue == value
}
}
🦾 Wrapping it up
Let's now wrap it up and use our ValuePickerOptions
view within our ValuePicker
:
public var body: some View {
NavigationLink {
List {
_VariadicView.Tree(ValuePickerOptions(selectedValue: selection)) {
content
}
}
...
} label: {
...
}
}
This is how it looks from the call-site:
ValuePicker("Name", selection: $selection) {
ForEach(["John", "Jean", "Juan"]) { name in
Text(verbatim: name)
.pickerTag(name)
}
}
Not bad, huh? It looks almost identical to the API we envisioned earlier! 💪
From now on, the possibilities are endless. You can tweak the implementation to allow multiple selections, empty value selection, and more.
📝 You can find the full code here.