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
DestinationViewpresentation ourselves, we can use the initializer without binding. - if we want to manage the
DestinationViewpresentation ourselves, we can use the second initializer, where we pass a$isShowingDestinationViewBinding<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
NavigationLinkrely to its own independent state, allNavigationLinks share the same state (theselectionbinding) - each
NavigationLinktriggers on a differentselectionvalue (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 DisclosureGroups:
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
Boolstate for each view group... - ...that then needs to be observed and acted upon when said state becomes
true(hiding the content of other previously openDisclosureGroups)
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
DisclosureGroups - 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 NavigationLinks 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
trueifshowingContent.wrappedValue == .a,falseotherwise - 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!