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:

  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.

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 bind it’s given, the container will pass that 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 use MyDisclosureGroup 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 to MyDisclosureGroup
  • renaming the original MyDisclosureGroup to OriginalMyDisclosureGroup, 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! 🚀

⭑⭑⭑⭑⭑