Hashable SwiftUI bindings
We've previously covered how to add optional bindings to SwiftUI views using DisclosureGroup
as an example.
Another SwiftUI view with similar API is NavigationLink
:
// No binding
NavigationLink(
"Go to view",
destination: DestinationView()
)
// With binding
NavigationLink(
"Go to view",
destination: DestinationView(),
isActive: $isShowingDestinationView
)
This works the same way as with DisclosureGroup
:
- if we don't need to manage the
DestinationView
presentation ourselves, we can use the initializer without binding. - if we want to manage the
DestinationView
presentation ourselves, we can use the second initializer, where we pass a$isShowingDestinationView
Binding<Bool>
.
NavigationLink
also provides a completely different, generic initializer:
enum ContentViewNavigation: Hashable {
case destinationA
case destinationB
case destinationC
}
...
NavigationLink(
"Go to view",
destination: DestinationA(),
tag: .destinationA,
selection: $showingNavigation
)
This initializer requires a tag
, which is a value of a generic Hashable
type, and a selection
, which binds over the same Hashable
type as tag
.
How can a view offer such different initializers?
In this article, we will try to extend DisclosureGroup
with a similar API.
But first, let’s have a look at the concepts behind NavigationLink
.
NavigationLink
NavigationLink
is one of the two SwiftUI views used for navigation, the other being NavigationView
:
among the two, NavigationLink
's role is to trigger and manage a navigation from one screen to another (a.k.a. the destination push/pop).
Each NavigationLink
controls the presentation of a single destination view.
With this being said, the reasoning behind the first two initializers should be clear:
NavigationLink(
"Go to view",
destination: DestinationView()
)
This first initializer lets SwiftUI own and manage the presentation of the destination.
NavigationLink(
"Go to view",
destination: DestinationView(),
isActive: $isShowingDestinationView
)
This second initializer lets us push/pop the destination programmatically as well.
Any view can only push up to one single view at any given time:
it doesn't make sense to push multiple views in the same stack at the same time.
This is where the last initializer comes in, where:
- instead of having each
NavigationLink
rely to its own independent state, allNavigationLink
s share the same state (theselection
binding) - each
NavigationLink
triggers on a differentselection
value (thetag
)
Here's an example of a view with three possible destinations:
enum ContentViewNavigation: Hashable {
case a // destination a
case b // destination b
case c // destination a
}
struct ContentView: View {
@State var showingContent: ContentViewNavigation?
var body: some View {
NavigationView {
VStack {
NavigationLink("Go to A", destination: Text("A"), tag: .a, selection: $showingContent)
NavigationLink("Go to B", destination: Text("B"), tag: .b, selection: $showingContent)
NavigationLink("Go to C", destination: Text("C"), tag: .c, selection: $showingContent)
}
}
}
}
Thanks to this third initialize, we no longer can (mistakenly) push multiple views simultaneously.
DisclosureGroup
Imagine a screen with multiple DisclosureGroup
s:
struct ContentView: View {
var body: some View {
List {
DisclosureGroup("Show content A") {
Text("Content A")
}
DisclosureGroup("Show content B") {
Text("Content B")
}
DisclosureGroup("Show content C") {
Text("Content C")
}
}
}
}
We now receive a new requirement, where only up to one DisclosureGroup
can display its content at any given time (therefore mimicking NavigationLink
's limitation).
Using just the official APIs, we'd need:
- a separate
Bool
state for each view group... - ...that then needs to be observed and acted upon when said state becomes
true
(hiding the content of other previously openDisclosureGroup
s)
One way to achieve this would be:
struct ContentView: View {
@State var isContentAShown: Bool = false
@State var isContentBShown: Bool = false
@State var isContentCShown: Bool = false
var body: some View {
List {
DisclosureGroup("Show content A", isExpanded: $isContentAShown) {
Text("Content A")
}
DisclosureGroup("Show content B", isExpanded: $isContentBShown) {
Text("Content B")
}
DisclosureGroup("Show content C", isExpanded: $isContentCShown) {
Text("Content C")
}
}
.onChange(of: isContentAShown) { newValue in
if newValue {
isContentBShown = false
isContentCShown = false
}
}
.onChange(of: isContentBShown) { newValue in
if newValue {
isContentAShown = false
isContentCShown = false
}
}
.onChange(of: isContentCShown) { newValue in
if newValue {
isContentAShown = false
isContentBShown = false
}
}
}
}
While this works, it's error-prone and costly to maintain:
the more groups the view has, the more onChange
view modifiers need to be added, the more State<Bool>
properties need to be declared, etc.
Besides, each group Bool
state is still independent of the rest: nobody can stop a rogue method to set all the DisclosureGroup
states to true
at once, resulting in undefined behavior (because of the onChange
observers).
Hashable Binding
Similarly to the last NavigationLink
example, it would be ideal if we could fix our current DisclosureGroup
solution shortcomings by:
- sharing a single state among all our
DisclosureGroup
s - making it impossible to have multiple groups showing the content at the same time
First, let's define a new Hashable
enum, with each case representing a separate section of our view:
enum ContentViewGroup: Hashable {
case a
case b
case c
}
We want to use this enum as our shared state, where each DisclosureGroup
listens to a separate case: when tapping on the DisclosureGroup
"A" for example, the state will be set to .a
, allowing the first group to show its content, while other groups keep the content hidden.
Using the same NavigationLink
approach, this is where we want to end up with:
struct ContentView: View {
@State var showingContent: ContentViewGroup?
var body: some View {
List {
DisclosureGroup(
"Tap to show content A",
tag: .a,
selection: $showingContent) {
Text("Content A")
}
DisclosureGroup(
"Tap to show content B",
tag: .b,
selection: $showingContent) {
Text("Content B")
}
DisclosureGroup(
"Tap to show content C",
tag: .c,
selection: $showingContent) {
Text("Content C")
}
}
}
}
Unfortunately, DisclosureGroup
doesn't offer such API.
But if it was offered, how would this initializer be exposed?
Looking at NavigationLink
s headers, we would have something like:
extension DisclosureGroup where Label == Text {
public init<V: Hashable, S: StringProtocol>(
_ label: S,
tag: V,
selection: Binding<V?>,
content: @escaping () -> Content) {
...
}
}
Before trying to fill in this initializer, let's take a step back and look at both NavigationLink
and DisclosureGroup
:
at any given time, a NavigationLink
is either pushing the destination or not, regardless of what initializer we use. Similarly, a DisclosureGroup
is either showing its content or not.
These views always have a boolean state (pushing/not-pushing, showing/not-showing), even when we pass the Hashable
tag
+ selection
binding combo.
The Hashable
initializer is a convenience:
behind the scenes, these views still behave as if a boolean binding has been passed.
Let's take a look at the first DisclosureGroup
definition in our example:
DisclosureGroup(
"Tap to show content A",
tag: .a,
selection: $showingContent,
content: { Text("Content A") }
)
If we had to turn the showingContent
Hashable
binding into a Bool
one, this is more or less how we'd do it:
- the value would be
true
ifshowingContent.wrappedValue == .a
,false
otherwise - when setting the boolean binding value to
true
, we'd reflect this change by settingshowingContent.wrappedValue = .a
- when setting the boolean binding value to
false
, we'd reflect this change by settingshowingContent.wrappedValue = nil
The Hashable
initializer has all it is needed to turn the Hashable
binding into a Bool
one:
despite not getting a Bool
binding, nobody is stopping us from creating a new one. This is what NavigationLink
does, and what we can do in DisclosureGroup
as well:
extension DisclosureGroup where Label == Text {
public init<V: Hashable, S: StringProtocol>(
_ label: S,
tag: V,
selection: Binding<V?>,
content: @escaping () -> Content) {
let boolBinding: Binding<Bool> = Binding(
get: { selection.wrappedValue == tag },
set: { newValue in
if newValue {
selection.wrappedValue = tag
} else {
selection.wrappedValue = nil
}
}
)
// Here we call the "normal" initializer with a Binding<Bool>.
self.init(
label,
isExpanded: boolBinding,
content: content
)
}
}
And with this extension, we've now accomplished our target:
Thanks to this generic approach, our code is much more maintainable, reusable, and less error-prone:
the final gist can be found here.
Conclusions
Despite offering multiple initializers, each SwiftUI view will run the same core logic at the end of the day.
The Hashable
convenience initializer is yet another example of how SwiftUI excels at progressive discovery:
taking any SwiftUI view, we can start by adopting their simple initializers first, where most of the details are hidden, and then, once we are acquaint with them, we can move and use, or even create, more advanced ones, where we have more control (and more responsibility!) of each view.
Have you seen any other SwiftUI examples of such APIs? Please let me know!
Thank you for reading, and stay tuned for more articles!