Implementing a custom ViewModifier where output is conditional on concrete View type (SwiftUI)

2.4k views Asked by At

I would like to create a ViewModifier where the output is conditional on the type of content it is modifying.

The best test of the concept I've managed (using Text and TextField as example View types) is as follows:

struct CustomModifier<T: View>: ViewModifier {
    @ViewBuilder func body(content: Content) -> some View {
        if content is Text.Type {
            content.background(Color.red)
        } else {
            if content is TextField<T>.Type {
                content.background(Color.blue)
            }
        } 
            content
    }
}

The problem with the above modifier is that you need to explicitly provide the generic term when you use the modifier so seems incorrect (and, to my nose, a code smell) as you then need to define a generic on the parent View and then a generic on its parent, etc, etc..

e.g.

struct ContentView<T: View>: View {
    var body: some View {
        VStack {
            Text("Hello world")
                .modifier(CustomModifier<Text>())
            
            TextField("Textfield", text: .constant(""))
                .modifier(CustomModifier<TextField<T>>())
        }
    }
}

I managed to get around this problem (with some guidance from Cristik) using this extension on View:

extension View {
    func customModifier() -> some View where Self:View {
        modifier(CustomModifier<Self>())
    }
}

The modifier was tested using the following using iPadOS Playgrounds:

struct ContentView: View {
    var body: some View {
        Form {
            Text("Hello")
                .customModifier()
            
            TextField("Text", text: .constant(""))
                .customModifier()
        }
    }
}

This compiles and runs but the output is not what I expected. The Text and TextField views should have different backgrounds (red and blue respectively) but they are displayed unchanged. Overriding the View type checking in the modifier (hard coding the type check to 'true') results in a background colour change so the modifier is being applied; it's the type check that's failing.

I dragged the code into Xcode to try and get a better idea of why this was happening and got an immediate compiler warning advising that the type check would always fail (the modifier name in the screenshot is different - please disregard):

Xcode compiler errors: Errors

This explains why the code does not perform as intended but I'm unable to determine whether I have made a mistake or if there is (in real terms) no way of checking the concrete type of a View sent to a ViewModifier. As best I can tell, the content parameter sent to a ViewModifier does seem to be type erased (based on the methods accessible in Xcode) but there does seem a way of obtaining type information in this context because certain modifiers (e.g. .focused()) only operate on certain types of View (specifically, interactive text controls) and ignore others. This could of course be a private API that we can't access (yet...?)

Any guidance / explanation?

1

There are 1 answers

2
Cristik On BEST ANSWER

You're right, there are some code smells in that implementation, starting with the fact that you need to write type checks to accomplish the goal. Whenever you start writing is or as? along with concrete types, you should think about abstracting to a protocol.

In your case, you need an abstraction to give you the background color, so a simple protocol like:

protocol CustomModifiable: View {
    var customProp: Color { get }
}

extension Text: CustomModifiable {
   var customProp: Color { .red }
}

extension TextField: CustomModifiable {
   var customProp: Color { .blue }
}

, should be the way to go, and the modifier should be simplifiable along the lines of:

struct CustomModifier: ViewModifier {
    @ViewBuilder func body(content: Content) -> some View {
        if let customModifiable = content as? CustomModifiable {
            content.background(customModifiable.customProp)
        } else {
            content
        }
    }
}

The problem is that this idiomatic approach doesn't work with SwiftUI modifiers, as the content received as an argument to the body() function is some internal type of SwiftUI that wraps the original view. This means that you can't (easily) access the actual view the modifier is applied to.

And this is why the is checks always failed, as the compiler correctly said.

Not all is lost, however, as we can work around this limitation via static properties and generics.

protocol CustomModifiable: View {
    static var customProp: Color { get }
}

extension Text: CustomModifiable {
    static var customProp: Color { .red }
}

extension TextField: CustomModifiable {
    static var customProp: Color { .blue }
}

struct CustomModifier<T: CustomModifiable>: ViewModifier {
    @ViewBuilder func body(content: Content) -> some View {
        content.background(T.customProp)
    }
}

extension View {
    func customModifier() -> some View where Self: CustomModifiable {
        modifier(CustomModifier<Self>())
    }
}

The above implementation comes with a compile time benefit, as only Text and TextField are allowed to be modified with the custom modifier. If the developer tries to apply the modifier on a non-accepted type of view, they-ll get a nice Instance method 'customModifier()' requires that 'MyView' conform to 'CustomModifiable', which IMO is better than deceiving about the behaviour of the modifier (i.e. does nothing of some views).

And if you need to support more views in the future, simply add extensions that conform to your protocol.