Conditional SwiftUI View Modifiers are Evil

Custom modifiers in SwiftUI are a great way to reuse code. However, that should never lead to obfuscating semantics. And, unfortunately, most use cases for conditional modifiers do both of those things. Here's why.

Okay, forgive me for the attention grabbing title; evil is a pretty harsh term and doesn’t quite convey the nuance. But there are plenty of misconceptions around conditional view modifiers, as well as widespread misuse and bad advice that is quietly killing your apps’ performance. Let’s unpack it.

Swift is an extremely expressive language by its design, allowing its users to design APIs that are syntactically lightweight but powerful at the same time. SwiftUI fully takes advantage of this exciting property of the language to abstract away a whole layer of concepts into a declarative easy-to-use API surface.

There are many things to love about SwiftUI’s approach to writing UI code. Even when the built-in functionality is not enough, the framework lets you go deeper by bridging your existing AppKit and UIKit controls, and, yes, writing new view modifiers.

Conditional Modifiers

Custom modifiers in SwiftUI are a great way to hide complexity and reuse UI concepts specific to your application. They can help you extract common concepts, making your code more expressive, easier to write and read. However, hiding complexity should never mean making code harder to reason about or lead to obfuscating semantics. And, unfortunately, most use cases for conditional modifiers do both of those things.

So, what are conditional modifiers? These are custom modifiers with a lightweight if syntax, allowing you to branch between 2 unrelated modifiers, conditionally applying one or the other, without having to write an if statement in the body of the declaring view. It could be declared like so:

extension View {
    func `if`<Content: View>(_ condition: Bool, modifier: (Self) -> Content) -> some View {
        if condition {
            modifier(self)
        } else {
            self
        }
    }
}

Such extensions are commonly used for dynamically hiding the View by conditionally applying the hidden modifier, or for applying a background to a View only when some condition is true.

_ConditionalContent in SwiftUI

What’s the issue with conditional modifiers then? The key to understanding this lies in result builders and what an if statement actually means to SwiftUI when you use it inside your View’s body or any other @ViewBuilder property or closure.

Under the hood, the if statement is represented by a View called _ConditionalContent. When you switch between 2 branches of an if statement, it has 2 generic parameters: one for the true branch of the expression, and one for the false branch. The role of this view is to switch between the 2 views it is wrapping when the result of the expression you’ve provided changes. The types of both views are known at compile time and used by SwiftUI internals.

The if statement ultimately allows different types of views to be displayed.

Let’s zoom in on “different types” here. Internally, SwiftUI will allocate and manage storage (including what can be referred to as a subgraph) for each of the branches of the conditional, but only one of them will ultimately be created and updated at a given time. It seems like it’s what we want, right?

Let’s consider one of the most common use cases for a conditional modifier wrapper: applying a background conditionally.

var body: some View {
    contentView
        .if(User.wantsGlassBackground) {
            $0.background(CustomGlassBackgroundView())
        }
}

This seems like a convenient way to modify content at first glance. But let’s remove the syntactic sugar and see what the view is actually evaluated to. It is essentially equivalent to the following:

var body: some View {
    if User.wantsGlassBackground {
        contentView
            .background(CustomGlassBackgroundView())
    } else {
        contentView
    }
}

If you print the type of each of the above views (via something like print("\(type(of: view))”)), you will find that both of them evaluate to something like the following, demonstrating equivalence.

_ConditionalContent<ModifiedContent<SampleContentView, _BackgroundModifier<CustomGlassBackgroundView>>, SampleContentView>

This is not optimal for various reasons. Here we actually create 2 top-level branches containing not quite the same, but actually very much different, View types. To put this into more canonical terms, the structural identity of each of the branches is distinct.

If you’d like to go deeper and understand where these additional View types come from, or even why knowing the types at compile time might be useful to SwiftUI, you have to meet AttributeGraph. I highly recommend this article by Rens Breur.
To better understand the concept of identity in SwiftUI, as well as the concept of a view’s lifetime, please refer to this WWDC21 talk.

So, to SwiftUI, your seemingly shorthand and convenient code actually means something really important and probably unexpected: instead of declaring a view with a conditionally applied background, you’re switching between 2 completely different views.

If you’re not careful, this has the potential to really hinder your application’s performance, especially if your contentView happens to contain a complex view hierarchy, bridged (NSViewRepresentable / UIViewRepresentable) types, or cause SwiftUI to set up and tear down a lot of platform views like Lists.

But, that’s not all. The accidentally different semantics will also impact the behavior of your view in very meaningful ways.

  • When you toggle the property and switch between branches, the branch about to go away will receive an onDisappear and the freshly true branch will receive an onAppear.
  • Because these are different views, SwiftUI might try to trigger a transition between them, causing you to see unnecessary and most likely weird animations and transient gaps in your layouts.

Both of the above behaviors would not be surprising to anyone reading the code if our declaration had intentionally used a standard ViewBuilder if statement at the root level. But our conditional modifier actually obfuscated our real intent, and would likely eventually force you or someone else on your team to debug a subtly wrong behavior or spend time investigating poor performance. So, in an effort to make your code a bit nicer to read and more convenient to write, you’ve made it harder to reason about and maintain.

Best Practices

I’ll try to enumerate some of the use cases for conditional modifiers I commonly see in SwiftUI code and provide concrete alternatives. Let’s start with the example of a conditional background we've explored above.

Conditional Background

There is a version of the background modifier (as well as other related modifiers like overlay) that accepts a @ViewBuilder closure and lets you express your intent more precisely.

var body: some View {
    contentView
        .background {
            if User.wantsGlassBackground {
                CustomGlassBackgroundView()
            }
        }
}

This doesn’t read overly verbose or weird at all. In fact, it’s easier to read because you’re not using some bespoke modifier that the reader of your code might have to go and look up elsewhere in your codebase.

So, what’s the type of this view?

ModifiedContent<SampleContentView, _BackgroundModifier<Optional<CustomGlassBackgroundView>>>

The benefit of this approach is clear. You’re describing a View that constructs a content view (however complex or deeply nested it may be), then applies a background to it. Within that background, you’re switching between a simpler CustomGlassBackgroundView and no view. Causing a background to be re-created is nowhere close to being as expensive as causing the whole view (including the background) and the associated storage and subgraph to be thrown away and recreated with a slightly different property.

Hiding a View

When people look at the hidden modifier, one of the first things they notice is that it is not conditional: it does not accept a Boolean flag allowing the user to conditionally enable or disable it. Some other modifiers, like allowsHitTesting(_:) or scrollClipDisabled(_:), do provide such a hook.

Since hiding a view conditionally is a very common use case, folks often reach for a conditional modifier like so:

var body: some View {
    contentView
        .if(shouldHide) { $0.hidden() }
}

Let’s develop some intuition by finding the equivalent declaration using the built-in ViewBuilder if syntax. The above would be equivalent to the following declaration:

var body: some View {
    if shouldHide {
        contentView
            .hidden()
    } else {
        contentView
    }
}

The type of these views would be something like _ConditionalContent<ModifiedContent<SampleContentView, _HiddenModifier>, SampleContentView>

The parallel to the previous background example is very likely obvious at this point. And, because this condition can be even more sneaky and compact, you’re probably even more likely to run into trouble and have to wonder why your hierarchy is all slow and messed up!

So, what do you do to conditionally hide a view then? Developers have different reasons for using hidden, of course, but assuming the canonical use case of hiding a view based on some condition, you’re much better off with a ternary expression inside the opacity modifier.

var body: some View {
    contentView
        .opacity(shouldHide ? 0 : 1)
}

The type of the view here is actually the simplest one yet, evaluating to ModifiedContent<SampleContentView, _OpacityEffect>.

You might be tempted to overoptimize this and also include allowsHitTesting, but that’s actually redundant. SwiftUI is smart enough to turn off hit testing by default for views that are fully transparent.

You might also be concerned about the unnecessary overhead related to applying the opacity of 1 when the view is visible, but in this case, again, SwiftUI is smart enough to recognize that the opacity of exactly 1 is effectively a no-op. The modifier of opacity(1.0) will be recognized as an inert modifier, and associated work will be skipped.

If you come from an AppKit or UIKit background, you might ask an even more insightful question. Even though the modifier is inert, isn’t it still causing unnecessary wrapping of our contentView into a modifier (which effectively creates another view) every time the body of our View is reevaluated, which could happen very frequently at SwiftUI’s discretion? Unlike AppKit or UIKit, SwiftUI Views have negligible overhead, especially very lightweight ones that don’t have heavy computations in their initializers or bodies. After all, remember that Views are structs.

On the other hand, throwing away large subgraphs and other pieces of internal storage, potentially even AppKit or UIKit views backing the contents of your views behind the scenes, is expensive and will affect your app’s performance if you’re not very intentional about when and where it happens.

Takeaways

Adding useful abstractions that make your view implementations easier to write and maintain is, of course, a completely worthwhile pursuit. In fact, sometimes a conditional modifier can even be a good idea – for instance, SwiftUI doesn’t provide an easy way to branch your Views based on the OS version that your customer is running. I am also by no means trying to advocate against the use of custom view modifiers. They are extremely useful, and the world of Apple platforms development would be a bleak place without ViewModifier in it.

Nonetheless, it is crucial to be very intentional about where you introduce abstractions, and that your abstractions don’t actually obfuscate the true semantics of your Views and relationships between them.

Fighting against the framework is rarely a good idea. The guardrails are always there for a reason – after all, we’ve always spent a lot of time and effort on API design at Apple, emphasizing concepts like progressive disclosure and making it easier to follow best practices. Instead, embrace the SwiftUI conventions: use the conveniences provided by @ViewBuilder, and carefully consider the implications of any changes you’re making to accommodate a particular use case.