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, all NavigationLinks share the same state (the selection binding)
  • each NavigationLink triggers on a different selection value (the tag)

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 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 open DisclosureGroups)

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 true if showingContent.wrappedValue == .a, false otherwise
  • when setting the boolean binding value to true, we'd reflect this change by setting showingContent.wrappedValue = .a
  • when setting the boolean binding value to false, we'd reflect this change by setting showingContent.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!

⭑⭑⭑⭑⭑

Further Reading

Explore SwiftUI

Browse all

Explore Swift

Browse all