How to add optional @Bindings to SwiftUI views
Among the new SwiftUI views from this year WWDC we have DisclosureGroup
.
DisclosureGroup
shows/hides its content based on a disclosure state:
DisclosureGroup(isExpanded: $showingContent) {
Text("Content")
} label: {
Text("Tap to show content")
}
What caught my eye is that DisclosureGroup
comes with a few initializers, some require a isExpanded
Binding<Bool>
parameter, some don't:
// No binding
DisclosureGroup {
Text("Content")
} label: {
Text("Tap to show content")
}
// With binding
DisclosureGroup(isExpanded: $showingContent) {
Text("Content")
} label: {
Text("Tap to show content")
}
How can a view deal with getting, and also not getting, a @Binding
? Let's create a new view mocking this API.
Why having these options?
In the WWDC20 session Data Essentials in SwiftUI
the SwiftUI team teaches us to ask the following questions when creating a new view:
- What data does this view need?
- How will the view manipulate that data?
- Where will the data come from?
- Who owns the data?
With DisclosureGroup
, it's clear that the isExpanded
state could be handled both internally and externally:
- internally, if the state doesn't effect any other part of the view hierarchy
- externally, if we want to access and manipulate this state somewhere else as well
It makes sense for DisclosureGroup
to expose and handle both options. Let's see how we can create this behavior ourselves.
Getting started
Despite isExpanded
not being present in all initializers, a Binding<Bool>
state is necessary for the view to work. Let's create a view that requires this binding:
struct FSDisclosureGroup<Label: View, Content: View>: View {
@Binding var isExpanded: Bool
@ViewBuilder var content: () -> Content
@ViewBuilder var label: Label
@ViewBuilder
var body: some View {
Button { isExpanded.toggle() } label: { label }
if isExpanded {
content()
}
}
}
We can now replace DisclosureGroup
in our code with FSDisclosureGroup
, everything works exactly in the same way:
FSDisclosureGroup(isExpanded: $showingContent) {
Text("Content")
} label: {
Text("Tap to show content")
}
This article aims to mimic the API and behavior of
DisclosureGroup
, not its UI.
Making the binding state optional
With FSDisclosureGroup
there's no way around it: it needs a Binding<Bool>
state.
However, it doesn't matter where this binding comes from, for example we can wrap FSDisclosureGroup
into a container that:
- acts as its public interface
- declares a
State<Bool>
If a binding is given, the container will pass it to FSDisclosureGroup
, otherwise it will pass its own state:
struct FSDisclosureGroupContainer<Label: View, Content: View>: View {
@State private var privateIsExpanded: Bool = false
var isExpanded: Binding<Bool>?
@ViewBuilder var content: () -> Content
@ViewBuilder var label: Label
var body: some View {
FSDisclosureGroup(
isExpanded: isExpanded ?? $privateIsExpanded,
content: content) {
label
}
}
}
We can now initialize FSDisclosureGroupContainer
by either passing or not a binding. The outcome will be the same:
// No binding
FSDisclosureGroupContainer {
Text("Content")
} label: {
Text("Tap to show content")
}
// With binding
FSDisclosureGroupContainer(isExpanded: $showingContent) {
Text("Content")
} label: {
Text("Tap to show content")
}
Making our public API pretty
Thanks to FSDisclosureGroupContainer
we have a way to handle both cases where a @Binding
is passed and not, however this view currently offers only the default initializer:
init(
isExpanded: Binding<Bool>? = nil,
@ViewBuilder content: @escaping () -> Content,
@ViewBuilder label: () -> Label
)
Having an optional isExpanded
parameter of type Binding<Bool>?
is a source of confusion: what does init(isExpanded: nil, ...)
do?
If we don't know the implementation details, this could raise quite a few eyebrows.
Therefore, let's create two new initializers instead:
- one will require no binding at all
- one will require a non-optional binding
struct FSDisclosureGroupContainer<Label: View, Content: View>: View {
@State private var privateIsExpanded: Bool = false
var isExpanded: Binding<Bool>?
@ViewBuilder var content: () -> Content
@ViewBuilder var label: Label
// No binding
init(
@ViewBuilder content: @escaping () -> Content,
@ViewBuilder label: () -> Label
) {
self.init(isExpanded: nil, content: content, label: label)
}
// With binding
init(
isExpanded: Binding<Bool>,
@ViewBuilder content: @escaping () -> Content,
@ViewBuilder label: () -> Label
) {
self.init(isExpanded: .some(isExpanded), content: content, label: label)
}
// Private!
private init(
isExpanded: Binding<Bool>? = nil,
@ViewBuilder content: @escaping () -> Content,
@ViewBuilder label: () -> Label
) {
self.isExpanded = isExpanded
self.content = content
self.label = label()
}
var body: some View {
FSDisclosureGroup(
isExpanded: isExpanded ?? $privateIsExpanded,
content: content) {
label
}
}
}
Our container now exposes two easy to understand initializers:
// No binding
init(@ViewBuilder content: @escaping () -> Content, @ViewBuilder label: () -> Label)
// With binding
init(isExpanded: Binding<Bool>, @ViewBuilder content: @escaping () -> Content, @ViewBuilder label: () -> Label)
This is much better, developers using these API immediately understand what they do, without worrying what's happening behind the scenes.
A container?
Let's review what we did so far:
- we've built a view,
FSDisclosureGroup
, with the actual implementation of our UI, which requires a binding - we've built a
FSDisclosureGroup
container,FSDisclosureGroupContainer
, letting developers useFSDisclosureGroup
by either passing a@Binding
, or not
Developers don't need to know how this works behind the scenes: FSDisclosureGroupContainer
is an implementation detail.
The first fundamental of Swift's API Design Guidelines is Clarity at the point of use
:
we should always strive to hide all the complexity of our views, while being clear on what they do.
With this in mind we can improve our code by:
- renaming
FSDisclosureGroupContainer
toFSDisclosureGroup
- renaming the original
FSDisclosureGroup
to_FSDisclosureGroup
, and "hiding it" by not exposing it
struct FSDisclosureGroup<Label: View, Content: View>: View {
@State private var privateIsExpanded: Bool = false
var isExpanded: Binding<Bool>?
@ViewBuilder var content: () -> Content
@ViewBuilder var label: Label
init(
@ViewBuilder content: @escaping () -> Content,
@ViewBuilder label: () -> Label
) {
self.init(isExpanded: nil, content: content, label: label)
}
init(
isExpanded: Binding<Bool>,
@ViewBuilder content: @escaping () -> Content,
@ViewBuilder label: () -> Label
) {
self.init(isExpanded: .some(isExpanded), content: content, label: label)
}
private init(
isExpanded: Binding<Bool>? = nil,
@ViewBuilder content: @escaping () -> Content,
@ViewBuilder label: () -> Label
) {
self.isExpanded = isExpanded
self.content = content
self.label = label()
}
var body: some View {
_FSDisclosureGroup(
isExpanded: isExpanded ?? $privateIsExpanded,
content: content
) {
label
}
}
}
// Private!
private struct _FSDisclosureGroup<Label: View, Content: View>: View {
@Binding var isExpanded: Bool
@ViewBuilder var content: () -> Content
@ViewBuilder var label: Label
@ViewBuilder
var body: some View {
Button { isExpanded.toggle() } label: { label }
if isExpanded {
content()
}
}
}
And with this last change, we've accomplished our goal! 🎉
Conclusions
The more we work with Swift, the more we see how we can expose powerful APIs, while also making them easy to use and even look simple. This is one of the best aspects of Swift and SwiftUI, and it's something that we should always strive to do in our own code as well.
Of course, I have no insights on the actual implementation of DisclosureGroup
, but just by finding a way on how to mimic it, we can really appreciate all the tremendous work that both the Swift and SwiftUI team put into making things simple for us.
What do you think? Do you have any alternative on how to build this view? Please let me know!
Thank you for reading and stay tuned for more SwiftUI articles! 🚀