genericsswiftuiswift-keypath

How to construct key path for encapsulated Toggle accessing a collection


I'm trying to build a new SwiftUI View that encapsulates a Toggle. The Toggle accesses a collection so that it can show up in a mixed state. I'm having difficulties constructing the proper KeyPath.

I've build the view as follows:

struct CornersView<C>: View where C: RandomAccessCollection  {
    @Binding var sources: C
    let keyPath: KeyPath<C.Element, Binding<ApertureCorners>>


    var body: some View {
        Toggle(sources: sources, isOn: keyPath.appending(path: \.upperLeftActive), label: {})
    }
}

#Preview {
    @State var sources = [ApertureCornerTreatment(radius: 1, corners: [], chamfered: false)]

    return CornersView(sources: $sources, keyPath: \.corners)
}

This gives me the following error on the last line in the preview: Key path value type 'ApertureCorners' cannot be converted to contextual type 'Binding<ApertureCorners>'

Oddly enough, if I would want a Toggle on chamfered in the preview I would write: Toggle(sources: $sources, isOn: \.chamfered, label: {}), which looks very similar to the current preview.

What did I do wrong?


Solution

  • In the future, please post a complete example. I've stubbed in some code to be able to reproduce your error, but you should have included all this in your post:

    struct ApertureCorners: OptionSet {
        var rawValue: UInt8
    
        static let upperLeft = Self(rawValue: 1 << 0)
    
        var upperLeftActive: Bool {
            get { contains(.upperLeft) }
            set {
                if newValue { insert(.upperLeft) }
                else { remove(.upperLeft) } }
        }
    }
    
    struct ApertureCornerTreatment {
        var radius: CGFloat
        var corners: ApertureCorners
        var chamfered: Bool
    }
    

    With those definitions, I can reproduce your error:

    struct CornersView<C>: View where C: RandomAccessCollection & MutableCollection {
        @Binding var sources: C
        let keyPath: KeyPath<C.Element, Binding<ApertureCorners>>
    
        var body: some View {
            Toggle(sources: sources, isOn: keyPath.appending(path: \.upperLeftActive), label: {})
        }
    }
    
    #Preview {
        @State var sources = [ApertureCornerTreatment(radius: 1, corners: [], chamfered: false)]
    
        return CornersView(
            sources: $sources,
            keyPath: \.corners
    //               ^ πŸ›‘ Key path value type 'ApertureCorners' cannot be converted to contextual type 'Binding<ApertureCorners>'
        )
    }
    

    The location of the error isn't really where you have a problem, though.

    The main problem is in your call to Toggle (line-wrapped for readability):

        Toggle(
            sources: sources,
            isOn: keyPath.appending(path: \.upperLeftActive),
            label: {}
        )
    

    You're passing sources, not $sources. So you're passing a plain array (type [ApertureCornerTreatment]) to Toggle. The isOn argument therefore needs to be a KeyPath<ApertureCornerTreatment, Binding<Bool>>.

    There are no Bindings in ApertureCornerTreatment or ApertureCorners, nor is there any obvious way to create one, so you're not going to be able to create a key path with that type.

    The trick, which is not explained in Toggles documentation, is this: whatever you pass as the sources argument to Toggle needs to be not just a RandomAccessCollection, but also something that can create Bindings.

    The only type I'm aware of that conforms to RandomAccessCollection and can create Bindings is… Binding itself, when its Value type is itself a RandomAccessCollection.

    So, let's change the code to pass $sources to Toggle, and get two new errors:

        Toggle(
    //  ^ πŸ›‘ Initializer 'init(sources:isOn:label:)' requires that 'C' conform to 'MutableCollection'
            sources: $sources,
            isOn: keyPath.appending(path: \.upperLeftActive),
    //                    ^ πŸ›‘ Cannot convert value of type 'KeyPath<C.Element, Binding<Bool>>' to expected argument type 'KeyPath<Binding<C>.Element, Binding<Bool>>'
            label: {}
        )
    

    We fix these errors by adding a requirement that C conform to MutableCollection, and by changing the type of the keyPath property:

    struct CornersView<C>: View where C: RandomAccessCollection & MutableCollection {
    //                                                 ADD THIS ^^^^^^^^^^^^^^^^^^^
        @Binding var sources: C
        let keyPath: KeyPath<Binding<C.Element>, Binding<ApertureCorners>>
    //           CHANGE THIS ^^^^^^^^^^^^^^^^^^
    
        var body: some View {
            Toggle(
                sources: $sources,
                isOn: keyPath.appending(path: \.upperLeftActive),
                label: {}
            )
        }
    }
    

    With those changes, Swift accepts the #Preview.