How SwiftUI handles multiple init options?

Hello, currently I'm trying replicate SwiftUI DisclosureGroup in my project, because I want to add custom disclosure indicator and appearance.

My main question is, how swiftui handles init with binding and without binding and how I can replicate that behaviour? For example DisclosureGroup has these inits:

public struct DisclosureGroup<Label, Content> : View where Label : View, Content : View {

    public init(@ViewBuilder content: @escaping () -> Content, @ViewBuilder label: () -> Label)

    public init(isExpanded: Binding<Bool>, @ViewBuilder content: @escaping () -> Content, @ViewBuilder label: () -> Label)

I want to support both init with Binding and without Binding.

My guess that I need some fallback state to use and create binding to that state
I have created my custom struct, but swift compiler show error that I am accessing value before being initialized

struct CustomDislosureGroup<Label, Content, Chevron>: View where Label: View, Content: View, Chevron: View {
  var label: Label
  var content: Content
  var chevron: Chevron
  var isContentPresented: Binding<Bool>
  @State private var _storageState: Bool = false

  init(@ViewBuilder content: @escaping () -> Content, @ViewBuilder label: @escaping () -> Label, @ViewBuilder chevron: @escaping () -> Chevron) {
    self.label = label()
    self.content = content()
    self.chevron = chevron()
    self.isContentPresented = Binding<Bool>(get: {
      _storageState
    }, set: {
      _storageState = $0
    })
  }
}

I use this pattern:

private let externalIsExpanded: Binding<Bool>?
@State private var internalIsExpanded = false
private var isExpanded: Binding<Bool> { externalIsExpanded ?? $internalIsExpanded }

init(
  isExpanded: Binding<Bool>? = nil,
  @ViewBuilder content: @escaping () -> Content,
  @ViewBuilder label: @escaping () -> Label
) {
  self.externalIsExpanded = isExpanded
  self.content = content
  self.label = label
}

When wanting to expose multiple implementations through the same API, I create multiple internal representations that are switched over by an enum, all pointing to the same underlying View. For the simple case of a single boolean, this doesn't seem like much of a benefit, but as the internal vs external representations require more state, I find it much cleaner to just use two (rarely more) separate internal types. This also prevents unused storage from being initialized.

private struct _DisclosureGroupContent: View {
    @Binding var isExpanded: Bool

    var body: some View {
        List {
            Toggle(isOn: $isExpanded) {
                Text(isExpanded ? "On" : "Off")
            }
        }
    }
}

private struct _ExternalDisclosureGroup: View {
    private var isOn: Binding<Bool>

    init(isOn: Binding<Bool>) {
        self.isOn = isOn
    }

    var body: some View {
        _DisclosureGroupContent(isExpanded: isOn)
    }
}

private struct _InternalDisclosureGroup: View {
    @State private var isOn = false

    init() {
        self.isOn = isOn
    }

    var body: some View {
        _DisclosureGroupContent(isExpanded: $isOn)
    }
}

struct DisclosureGroup: View {
    private enum Storage {
        case `internal`
        case external(Binding<Bool>)
    }

    private let storage: Storage

    init() {
        self.storage = .internal
    }

    init(isOn: Binding<Bool>) {
        self.storage = .external(isOn)
    }

    var body: some View {
        switch storage {
        case .internal:
            _InternalDisclosureGroup()
        case .external(let binding):
            _ExternalDisclosureGroup(isOn: binding)
        }
    }
}
1 Like

Thank you, I think that is what I needed. I wouldn't figure out how to implement this.

Here's my take on it. Not fully tested, be sure to do that.

The problem is that, since CustomDislosureGroup is a struct and Binding uses escaping closures, the "snapshot" you take in the closure doesn't have the isContentPresented initialized yet, thus the error.

To solve it, use a secondary private helper. Here's the code.

private struct _CustomDislosureGroup<V: View>: View {
    private let label: () -> V
    private let content: () -> V
    private let chevron: () -> V
    private let isContentPresented: Binding<Bool>

    init(isContentPresented: Binding<Bool>,
         @ViewBuilder content: @escaping () -> V,
         @ViewBuilder label: @escaping () -> V,
         @ViewBuilder chevron: @escaping () -> V)
    {
        self.label = label
        self.content = content
        self.chevron = chevron
        self.isContentPresented = isContentPresented
    }

    var body: some View {
        Text("")
    }
}

struct CustomDislosureGroup<V: View>: View {
    private let label: () -> V
    private let content: () -> V
    private let chevron: () -> V
    private var isContentPresentedBinding: Binding<Bool>?
    @State private var isContentPresented: Bool

    init(isContentPresented externalBiding: Binding<Bool>? = nil,
         @ViewBuilder content: @escaping () -> V,
         @ViewBuilder label: @escaping () -> V,
         @ViewBuilder chevron: @escaping () -> V)
    {
        self.label = label
        self.content = content
        self.chevron = chevron
        self.isContentPresented = externalBiding?.wrappedValue ?? true
        self.isContentPresentedBinding = externalBiding
    }

    var body: some View {
        _CustomDislosureGroup(
            isContentPresented: isContentPresentedBinding ?? $isContentPresented,
            content: content,
            label: label,
            chevron: chevron
        )
    }
}

This has the advantage of keeping the state and the binding in sync (you can pass the binding down to child views).
Alternatively, you can have two init methods as you initially proposed (I've avoided that for brevity, but I like it better as well)

Also, I've made a couple of other small changes:

  • label, content and chevron should be let, you're never going to mutate those
  • unless you specifically need to access a method on any of the input types (which is really rare, probably not applying in your case), they're all views for the purpose of the current view. This will allow you to easily pass any view, without adding conformances, which I think it's faster and easier.
  • its good practice to hold on the view builder instead of the view. This way you can recreate it at the right time.
  • all declarations should be private, avoid polluting the interface + allow making assumptions about the scope of the binding (aka, only subviews will be able to use it)

EDIT: avoid using final class

Yeah, that would be great. Unfortunately SwiftUI protocol View cannot be used by a class. Swift language allow that construction, but at runtime app will crash with EXC_BAD_INSTRUCTION. You can checkout this Can a class be a View in SwiftUI? - Stack Overflow

That's interesting, I remember I used final class before, but probably I don't remember how I had to rollback because of this since it seems it's a known issue.
In any case, updated my answer accordingly.

@zntfdr wrote an article on this: How to add optional @Bindings to SwiftUI views

1 Like