Why Conditional View Modifiers are a Bad Idea
In the SwiftUI community, many people come up with their own version of a conditional view modifier. It allows you to take a view, and only apply a view modifier when the condition holds. It typically looks something like this:
// Please don't use this:
extension View {
@ViewBuilder
func applyIf<M: View>(condition: Bool, transform: (Self) -> M) -> some View {
if condition {
transform(self)
} else {
self
}
}
}
There are many blog posts out there with similar modifiers. I think all these blog posts should come with a huge warning sign. Why is the above code problematic? Let's look at a sample.
In the following code, we have a single state property myState
. When it changes between true
and false
, we want to conditionally apply a frame:
struct ContentView: View {
@State var myState = false
var body: some View {
VStack {
Toggle("Toggle", isOn: $myState.animation())
Rectangle()
.applyIf(condition: myState, transform: { $0.frame(width: 100) })
}
// ...
}
}
Interestingly, when running this code, the animation does not look smooth at all. If you look closely, you can see that it fades between the “before” and “after” state:
Here's the same example, but written without applyIf
:
struct ContentView: View {
@State var myState = false
var body: some View {
VStack {
Toggle("Toggle", isOn: $myState.animation())
Rectangle()
.frame(width: myState ? 100 : nil)
}
// ...
}
}
And with the code above, our animation works as expected:
Why is the applyIf
version broken? The answer teaches us a lot about how SwiftUI works. In UIKit, views are objects, and objects have inherent identity. This means that two objects are equal if they are the same object. UIKit relies on the identity of an object to animate changes.
In SwiftUI, views are structs — value types — which means that they don't have identity. For SwiftUI to animate changes, it needs to compare the value of the view before the animation started and the value of the view after the animation ends. SwiftUI then interpolates between the two values.
To understand the difference in behavior between the two examples, let's look at their types. Here's the type of our Rectangle().applyIf(...)
:
_ConditionalContent<ModifiedContent<Rectangle, _FrameLayout>, Rectangle>
The outermost type is a _ConditionalContent
. This is an enum that will either contain the value from executing the if
branch, or the value from executing the else
branch. When condition changes, SwiftUI cannot interpolate between the old and the new value, as they have different types. In SwiftUI, when you have an if/else
with a changing condition, a transition happens: the view from the one branch is removed and the view for the other branch is inserted. By default, the transition is a fade, and that's exactly what we are seeing in the applyIf
example.
In contrast, this is the type of Rectangle().frame(...)
:
ModifiedContent<Rectangle, _FrameLayout>
When we animate changes to the frame properties, there are no branches for SwiftUI to consider. It can just interpolate between the old and new value and everything works as expected.
In the Rectangle().frame(...)
example, we made the view modifier conditional by providing a nil
value for the width. This is something that almost every view modifier support. For example, you can add a conditional foreground color by using an optional color, you can add conditional padding by using either 0 or a value, and so on.
Note that applyIf
(or really, if/else
) also breaks your animations when you are doing things correctly on the “inside”.
Rectangle()
.frame(width: myState ? 100 : nil)
.applyIf(condition) { $0.border(Color.red) }
When you animate condition
, the border will not animate, and neither will the frame. Because SwiftUI considers the if/else
branches separate views, a (fade) transition will happen instead.
There is yet another problem beyond animations. When you use applyIf
with a view that contains a @State
property, all state will be lost when the condition changes. The memory of @State
properties is managed by SwiftUI, based on the position of the view in the view tree. For example, consider the following view:
struct Stateful: View {
@State var input: String = ""
var body: some View {
TextField("My Field", text: $input)
}
}
struct Sample: View {
var flag: Bool
var body: some View {
Stateful().applyIf(condition: flag) {
$0.background(Color.red)
}
}
}
When we change flag
, the applyIf
branch changes, and the Stateful()
view has a new position (it moved to the other branch of a _ConditionalContent
). This causes the @State
property to be reset to its initial value (because as far as SwiftUI is concerned, a new view was added to the hierarchy), and the user's text is lost. The same problem also happens with @StateObject
.
The tricky part about all of this is that you might not see any of these issues when building your view. Your views look fine, but maybe your animations are a little funky, or you sometimes lose state. Especially when the condition doesn't change all that often, you might not even notice.
I would argue that all of the blog posts that suggest a modifier like applyIf
should have a big warning sign. The downsides of applyIf
and its variants are not at all obvious, and I have unfortunately seen a bunch of people who have just copied this into their code bases and were very happy with it (until it became a source of problems weeks later). In fact, I would argue that no code base should have this function. It just makes it way too easy to accidentally break animations or state.
If you're interested in understanding how SwiftUI works, you could read our book Thinking in SwiftUI, watch our SwiftUI videos on Swift Talk, or attend one of our workshops.