Home > Enterprise >  Generic where clauses with views
Generic where clauses with views

Time:05-17

I have a project that allows the user to input or edit a variety of class properties. Most, if not all, of these properties will have the same input field depending on the property type; e.g. String properties will always need a TextField with uniform modifiers, Int fields will always have a text field with a number format, etc. Custom types will also need this functionality. What's more, the list of properties to edit (and thus their type) are not known at compile time. As such I wanted to solve this using generics.

The following simplified example shows a test input view that combines an object and a keypath into a binding, which can then be plugged into a view.

struct TestView: View {
    let testObject = TestObject()
    
    var body: some View {
        VStack {
            TestInputView(object: testObject, keyPath: \.string) { binding in
                TextField("Enter text...", text: binding)
            }
            TestInputView(object: testObject, keyPath: \.integer) { binding in
                TextField("Enter number...", value: binding, format: .number)
                    .keyboardType(.numberPad)
                    .foregroundColor(.primary)
            }
        }
    }
}

class TestObject: ObservableObject {
    var integer: Int = 1
    var string: String = "Test"
}

struct TestInputView<ValueType, Content: View>: View {
    let content: Content
    
    init(object: TestObject, keyPath: ReferenceWritableKeyPath<TestObject, ValueType>, @ViewBuilder contentBuilder: (Binding<ValueType>) -> Content) {
        self.content = contentBuilder(.init(
            get: { object[keyPath: keyPath]},
            set: { object[keyPath: keyPath] = $0 }
        ))
    }
    
    var body: some View {
        content
    }
}

However, I want the actual input fields to be defined in the TestInputView struct, because all string properties will need the same textfields, integers need the same formatted text fields, etc. So it makes sense to define the actual content based on ValueType, using generic where clauses.

But doing this causes the compiler to complain about Content. The only way to resolve this is by explicitly specializing the Content generic type to whatever the input field will be:

extension TestInputView where ValueType == String {
    init(object: TestObject, keyPath: ReferenceWritableKeyPath<TestObject, ValueType>) 
    /* Compiler says "Cannot convert value of type 'TextField<Text>' to closure result type 'Content'" without this clause */
    where Content == TextField<Text> {
        self.init(object: object, keyPath: keyPath) { binding in
            TextField("Enter text...", text: binding)
        }
    }
}

This defeats the point of the whole Content/some View dance imo. And it also breaks down as soon as you apply view modifiers:

extension TestInputView where ValueType == Int {
    init(object: TestObject, keyPath: ReferenceWritableKeyPath<TestObject, ValueType>) {
        self.init(object: object, keyPath: keyPath) { binding in
            TextField("Enter number...", value: binding, format: .number)
                .keyboardType(.numberPad)
                .foregroundColor(.primary)
        }
    }
    /* Cannot convert value of type 'some View' to closure result type 'Content' */
}

I don't think I can use opaque return types like some View in generic constraints? (Besides using Content: View, which I've already done).

Several guides online concerning views and generic types show pretty much exactly the same structure when it comes to Content: View generic types, but for some reason I can't get it to work.

Other things I have tried:

  • Insert where Content: View in pretty much every possible location, to no avail.
  • My original thought was to not use a viewbuilder or Content generic at all, but instead define a computed property, or even a function, with a generic where clause:
struct TestInputView<ValueType>: View {
    @ObservedObject var object: TestObject
    var keyPath: ReferenceWritableKeyPath<TestObject, ValueType>
    
    var binding: Binding<ValueType> {
        .init(
            get: { object[keyPath: keyPath]},
            set: { object[keyPath: keyPath] = $0 }
        )
    }
    
    var body: some View {
        inputField()
    }
    
    // Should only be called if none of the other definitions are satisfied (I thought)
    func inputField() -> some View {
        Text("Value type \(String(describing: ValueType.self)) is not implemented")
    }

    func inputField() -> some View where ValueType == String {
        TextField("Enter text...", text: binding)
    }

    func inputField() -> some View where ValueType == Int {
        TextField("Enter number...", value: binding, format: .number)
            .keyboardType(.numberPad)
            .foregroundColor(.primary)
    }
}

// This doesn't work either....
extension TestInputView where ValueType == String {
    var body: some View {
        inputField
    }
    
    var inputField -> some View {
        TextField("Enter text...", text: binding)
    }
}

However in both cases the resulting view shows "value type is not implemented". This is beyond the scope of my question but bonus points if anybody knows why this won't work either.

CodePudding user response:

I think you could try a type erasure approach. If you constrain your extensions to Content == AnyView, it might end up being what you're looking for:

extension TestInputView where ValueType == String, Content == AnyView {
    init(object: TestObject, keyPath: ReferenceWritableKeyPath<TestObject, ValueType>) {
        self.init(object: object, keyPath: keyPath) { binding in
            AnyView(erasing: TextField("Enter text...", text: binding))
        }
    }
}

extension TestInputView where ValueType == Int, Content == AnyView {
    init(object: TestObject, keyPath: ReferenceWritableKeyPath<TestObject, ValueType>) {
        self.init(object: object, keyPath: keyPath) { binding in
            AnyView(
                erasing: TextField("Enter number...", value: binding, format: .number)
                            .keyboardType(.numberPad)
                            .foregroundColor(.primary)
            )
        }
    }
}

In that case your TestView will look like this:

struct TestView: View {
    @StateObject var testObject = TestObject()

    var body: some View {
        VStack {
            TestInputView(object: testObject, keyPath: \.string)
            TestInputView(object: testObject, keyPath: \.integer)
        }
    }
}

Note that I also changed let testObject to @StateObject var testObject, otherwise it would get reinstantiated every time a view is recreated.

CodePudding user response:

I propose to reverse mind and put generics extension not in view, but in type, because in this scenario actually a value type is an entity that configures dependency.

Tested with Xcode 13.3 / iOS 15.4

demo

Main part of idea:

protocol Inputable {
    associatedtype V: View
    @ViewBuilder static func inputField(_ binding: Binding<Self>) -> Self.V
}

extension String: Inputable {
    static func inputField(_ binding: Binding<Self>) -> some View {
        TextField("Enter text...", text: binding)
    }
}

and usage

struct TestInputView<ValueType: Inputable>: View {
    // ... other code

    var body: some View {
        ValueType.inputField(binding)
    }
}

Complete test module in project is here

  • Related