How to add optional @Bindings to SwiftUI views

SwiftUI
30 June 2020

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:

  1. What data does this view need?
  2. How will the view manipulate that data?
  3. Where will the data come from?
  4. 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 use FSDisclosureGroup 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 to FSDisclosureGroup
  • 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! 🚀

⭑⭑⭑⭑⭑