Adding 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
?
In this article we’re going to try to create a View with the same API.
But first, let’s have a look at the concepts behind DisclosureGroup
.
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.
For DisclosureGroup
it makes sense to expose and handle both options.
Let’s see how we can mimic 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 MyDisclosureGroup<Label, Content>: View where Label: View, Content: View {
@Binding var isExpanded: Bool
var content: () -> Content
var label: () -> Label
@ViewBuilder
var body: some View {
Button(action: { isExpanded.toggle() }, label: label)
if isExpanded {
content()
}
}
}
We can now replace DisclosureGroup
in our code with MyDisclosureGroup
, and everything works exactly in the same way:
MyDisclosureGroup(isExpanded: $showingContent) {
Text("Content")
} label: {
Text("Tap to show content")
}
This article aims to copy the API and behavior of DisclosureGroup
, not its UI.
Making the Binding State Optional
With MyDisclosureGroup
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 MyDisclosureGroup
into a container that:
- acts as its public interface
- declares a
State<Bool>
If a binding is given, the container will pass it to MyDisclosureGroup
, otherwise it will pass its own state:
struct MyDisclosureGroupContainer<Label, Content>: View where Label: View, Content: View {
@State private var privateIsExpanded: Bool = false
var isExpanded: Binding<Bool>?
var content: () -> Content
var label: () -> Label
var body: some View {
MyDisclosureGroup(
isExpanded: isExpanded ?? $privateIsExpanded,
content: content,
label: label
)
}
}
We can now initialize MyDisclosureGroupContainer
by either passing or not a binding.
The outcome will be the same:
// no binding
MyDisclosureGroupContainer {
Text("Content")
} label: {
Text("Tap to show content")
}
// with binding
MyDisclosureGroupContainer(isExpanded: $showingContent) {
Text("Content")
} label: {
Text("Tap to show content")
}
Making our Public API Pretty
Thanks to MyDisclosureGroupContainer
we now have a way to handle both cases where a @Binding
is passed and not, however this view currenly offers only the default initializer:
init(isExpanded: Binding<Bool>? = nil, content: @escaping () -> Content, label: @escaping () -> 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 a non-optional binding
- one will require no binding at all
struct MyDisclosureGroupContainer<Label, Content>: View where Label: View, Content: View {
@State private var myIsExpanded: Bool = false
private var isExpanded: Binding<Bool>?
var content: () -> Content
var label: () -> Label
init(isExpanded: Binding<Bool>, content: @escaping () -> Content, label: @escaping () -> Label) {
self.init(isExpanded: .some(isExpanded), content: content, label: label)
}
init(content: @escaping () -> Content, label: @escaping () -> Label) {
self.init(isExpanded: nil, content: content, label: label)
}
// private!
private init(isExpanded: Binding<Bool>?, content: @escaping () -> Content, label: @escaping () -> Label) {
self.isExpanded = isExpanded
self.content = content
self.label = label
}
var body: some View {
MyDisclosureGroup(
isExpanded: isExpanded ?? $myIsExpanded,
content: content,
label: label
)
}
}
With this in place, our container now exposes two, easy to understand initializers:
// with binding
init(isExpanded: Binding<Bool>, content: @escaping () -> Content, label: @escaping () -> Label)
// without binding
init(content: @escaping () -> Content, label: @escaping () -> Label)
This is much better, developers using these API immediately understand what they do, without worrying what’s happening behind the scene.
A Container?
Let’s review what we did so far:
- we’ve built a view,
MyDisclosureGroup
, with the actual implementation of our UI, which requires a binding. - we’ve built a
MyDisclosureGroup
container,MyDisclosureGroupContainer
, which lets developers useMyDisclosureGroup
by either passing a@Binding
, or not.
Note how the developer doesn’t really need to know how this works behind the scenes:
MyDisclosureGroupContainer
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
MyDisclosureGroupContainer
toMyDisclosureGroup
- renaming the original
MyDisclosureGroup
toOriginalMyDisclosureGroup
, and hiding it, for example by putting it inside the “container”
struct MyDisclosureGroup<Label, Content>: View where Label: View, Content: View {
@State private var myIsExpanded: Bool = false
private var isExpanded: Binding<Bool>?
var content: () -> Content
var label: () -> Label
public init(isExpanded: Binding<Bool>, content: @escaping () -> Content, label: @escaping () -> Label) {
self.init(isExpanded: .some(isExpanded), content: content, label: label)
}
public init(content: @escaping () -> Content, label: @escaping () -> Label) {
self.init(isExpanded: nil, content: content, label: label)
}
private init(isExpanded: Binding<Bool>?, content: @escaping () -> Content, label: @escaping () -> Label) {
self.isExpanded = isExpanded
self.content = content
self.label = label
}
private struct OriginalDisclosureGroup<Label, Content>: View where Label: View, Content: View {
@Binding var isExpanded: Bool
var content: () -> Content
var label: () -> Label
@ViewBuilder
var body: some View {
Button(action: { isExpanded.toggle() }, label: label)
if isExpanded {
content()
}
}
}
var body: some View {
OriginalDisclosureGroup(
isExpanded: isExpanded ?? $myIsExpanded,
content: content,
label: label
)
}
}
And with this last change, we’ve accomplished our goal! 🎉
The final gist can be found here.
Conclusions
The more I work with Swift, the more I 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! 🚀
⭑⭑⭑⭑⭑