Tue. Oct 26th, 2021
Why Conditional View Modifiers are a Bad Idea · objc.io

In the SwiftUI community, many people create their own version of a conditional view modifier
. It allows you to get a preview and only apply a preview modifier when the condition is maintained. It usually looks like this:

								
extension View {
    @ViewBuilder
    func applyIfM: View>(condition: Bool, transform: (Self) -> M) -> some View {
        if condition {
            transform(self)
        } else {
            self
        }
    }
}

							

There are many posts out there with similar modifiers. I think all these blog posts should come with a big warning sign. Why is the above code problematic? Let’s look at an example.

In the following code, we have a single state property. myState
. when 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 doesn’t look at all uniform. If you look closely, you can see that it disappears between the “before” and “after” state:

Here is 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:

because it is the applyIf
broken version? 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 the same if they are the same object. UIKit relies on an object’s identity to animate changes.

In SwiftUI, views are structs – value types – which means they have no identity. In order for SwiftUI to animate the 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 is the type of our Rectangle().applyIf(...)
:

								_ConditionalContent<ModifiedContent<Rectangle, _FrameLayout>, Rectangle>

							

The outermost type is a _ConditionalContent
. This is an enum that will any
contain the value of the execution of the if
branch, or
the value of running the else
branch. When the condition changes, SwiftUI cannot interpolate between the old and the new value as they have different types. In SwiftUI, when you have a if/else
with a changing condition, a transition
happens: the view from one branch is removed and the view from the other branch is inserted. By default, the transition is a fade, and that’s exactly what we’re seeing in applyIf
example.

In contrast, this is the type of Rectangle().frame(...)
:

								ModifiedContent<Rectangle, _FrameLayout>

							

When we animate changes to frame properties, there are no ramifications for SwiftUI to consider. It can just interpolate between the old and the new value and everything works as expected.

At the Rectangle().frame(...)
example, we make the view modifier conditional by providing a nil
value for the width. This is something that almost all view modifiers support. For example, you can add a conditional foreground color using an optional color, you can add conditional fill using 0 or a value, and so on.

Notice that applyIf
(or really, if/else
) also interrupts your animations when you’re doing things right “from the inside”.

								Rectangle()
    .frame(width: myState ? 100 : nil)
    .applyIf(condition) { $0.border(Color.red) }

							

when you cheer condition
, the border will not be animated and neither will the frame. Because SwiftUI considers the if/else
branches separate views, a transition (fade) will take place in its place.

There is another problem besides the animations. when do you use applyIf
with a vision that contains a @State
property, all state will be lost when the condition is changed. the memory of @State
properties are managed by SwiftUI, based on the view’s position 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
, a applyIf
branch changes, and the Stateful()
view has a new position (changed to the other branch of a _ConditionalContent
) This causes the @State
to be reset to its initial value (because, as far as SwiftUI is concerned, a new view has been added to the hierarchy), and the user text is lost. The same problem also happens with @StateObject
.

The tricky part of all this is that you might not see any of these issues when building your view. Your previews look good, but maybe your animations are a little weird or you sometimes lose state. Especially when the condition doesn’t change that often, you may not even notice.

I would say all blog posts that suggest a modifier like applyIf
must have a big warning sign. the disadvantages of applyIf
and its variants aren’t obvious at all and unfortunately I’ve seen a lot of people who have just copied this into their codebases and were very happy about it (until it became a source of trouble weeks later). In fact, I would say that no codebase should have this function
. This just makes it very easy to accidentally break animations or states.

If you’re interested in understanding how SwiftUI works, you can read our Thinking in SwiftUI book, watch our SwiftUI videos on Swift Talk, or join one of our workshops.

Leave a Reply

Your email address will not be published. Required fields are marked *