Remember when we first discovered @ViewBuilder in Part 1? That moment when everything clicked and you thought “wow, I can create my own SwiftUI-style components!” It was pretty exciting, wasn’t it? The declarative syntax, the composability, the way it all just… worked.
Well, I hate to be the bearer of bad news, but today we’re going to talk about a pattern that might seem like the next logical step—but is actually a beautiful trap waiting to catch enthusiastic SwiftUI developers.
We’re diving into conditional ViewBuilders. On the surface, they look elegant and feel like a natural evolution of what we learned. Under the hood? They’re a debugging nightmare that can make your apps behave in mysteriously unpredictable ways.
Let me tell you a story about why this matters, and more importantly, how to avoid the pitfalls I (and many others) have fallen into.
A Quick Refresher (and Where Things Get Tempting)
In Part 1, we built some pretty cool reusable components. Remember our CustomCard?
struct CustomCard<Content: View>: View {
let content: () -> Content
init(@ViewBuilder content: @escaping () -> Content) {
self.content = content()
}
var body: some View {
VStack {
content
}
.padding()
.background(.gray.opacity(0.1))
.cornerRadius(12)
}
}It felt great, right? Clean, reusable, SwiftUI-esque. Naturally, your next thought was probably: “What if I could make my modifiers conditional too?” And that’s where things get… interesting.
The “Brilliant” Idea: Conditional ViewBuilders
The Pattern That Looks Too Good to Be True
So you start thinking: “I’m constantly writing code like this:”
Text("Hello")
.background(isHighlighted ? .yellow : .clear)
.font(isLarge ? .largeTitle : .body)“Wouldn’t it be cleaner if I could write:”
Text("Hello")
.applyIf(isHighlighted) { $0.background(.yellow) }
.applyIf(isLarge) { $0.font(.largeTitle) }I mean, look at it! It’s so readable, so declarative, so… SwiftUI-like! Here’s the implementation you probably came up with:
extension View {
@ViewBuilder
func applyIf<V: View>(_ condition: Bool, transform: (Self) -> V) -> some View {
if condition {
transform(self)
} else {
self
}
}
}When It Feels Like Magic
And at first, it works! Your code looks cleaner:
// Look how clean this is!
Text("Status")
.applyIf(isError) { $0.foregroundColor(.red) }
.applyIf(isBold) { $0.fontWeight(.bold) }
.applyIf(needsBorder) {
$0.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(.gray, lineWidth: 1)
)
}You show it to your teammates. They love it. You start using it everywhere. Life is good. Until it isn’t.
When the Magic Turns Into a Nightmare
The Bug That Made Me Question Everything
Let me tell you about the day I discovered why this pattern is problematic. I was working on a user profile screen with a simple counter component. Nothing fancy:
struct Counter: View {
@State private var highlighted = false
var body: some View {
VStack {
SubViewCounterView()
.applyIf(highlighted) {
$0.background(Color.red)
}
Button("toogle style") { highlighted.toggle() }
}
}
}
struct SubViewCounterView: View {
@State private var count = 0
var body: some View {
Button("count: \(count)") {
count += 1
}
}
}I clicked “Count” a few times. Got to 5. Then I clicked “Highlight” to see the red text. The count went back to 0. Wait, what?
The “Ghost in the Machine” Problem After hours of debugging (and maybe a few frustrated sighs), I discovered what was happening. SwiftUI wasn’t seeing the same view when the condition changed. From SwiftUI’s perspective:
- When
highlighted = false: It sees a Text view - When
highlighted = true: It sees a Text + foregroundStyle view
These are completely different types to SwiftUI’s type system! So when the condition flipped, SwiftUI thought “oh, this is a new view” and threw away all the state associated with the old one.
The Smoking Gun: What Really Happens Under the Hood
When I investigated what SwiftUI was actually creating, I built a simple debug tool:
extension View {
func debugViewTree() -> some View {
let mirror = Mirror(reflecting: self)
print("🔍 View type: \(type(of: self))")
if let storage = mirror.children.first(where: { $0.label == "storage" }) {
let storageMirror = Mirror(reflecting: storage.value)
print(" 📦 Storage type: \(type(of: storage.value))")
print(" 📦 Storage children: \(storageMirror.children.map { $0.label ?? "unknown" })")
}
for child in mirror.children {
if let label = child.label {
print(" └─ \(label): \(type(of: child.value))")
}
}
return self
}
}Here’s what I discovered when I applied it to our problematic code:
🔍 View type: AnyView
📦 Storage type: AnyViewStorage<_ConditionalContent<
ModifiedContent<SubViewCounterView, _BackgroundStyleModifier<Color>>,
SubViewCounterView
>>With Ternary Operator:
🔍 View type: ModifiedContent<SubViewCounterView, _BackgroundStyleModifier<Color>>
└─ content: SubViewCounterView
└─ modifier: _BackgroundStyleModifier<Color>Notice the difference? The conditional ViewBuilder forces SwiftUI into complex type gymnastics with
AnyViewand_ConditionalContent, while the ternary approach creates exactly the same type regardless of the condition.
Why This Happens (The Technical Deep Dive)
If you’re like me, you probably want to understand why this happens. The answer lies in how SwiftUI’s diffing algorithm works.
SwiftUI is incredibly smart about updating only what needs to change. It does this by comparing view types and structures. But here’s the kicker: when you use conditional ViewBuilders, you’re literally creating different view types:
// When condition is true: ModifiedContent<Text, BackgroundModifier>
// When condition is false: Text
// SwiftUI sees these as completely different things!It’s like telling SwiftUI “replace this Toyota with this Boeing 747” instead of “change the Toyota’s color from blue to red.”
The Performance Hit Nobody Talks About
Beyond the bugs, there’s a hidden performance cost. Every time your condition changes, SwiftUI has to:
- Destroy the old view hierarchy
- Create a new view hierarchy
- Re-layout everything
- Lose any cached optimizations
Meanwhile, with simple parameter changes:
.background(condition ? .red : .clear)SwiftUI just updates the background color. Same view, different parameter. Fast, efficient, predictable.
The Road to Recovery: Better Alternatives
Embrace the Ternary (It’s Your Friend)
I know, I know. Ternary operators don’t look as “clean.” But they work:
Text("Hello")
.foregroundColor(isError ? .red : .primary)
.fontWeight(isBold ? .bold : .regular)
.background(isHighlighted ? .yellow : .clear)Is it as “pretty” as our conditional ViewBuilder? Maybe not. But it’s reliable, fast, and won’t make your app behave like it’s possessed.
When Conditional ViewBuilders Are Actually OK
I don’t want to be totally unfair to conditional ViewBuilders. There are a few cases where they can work:
extension View {
@ViewBuilder
func adaptToDevice() -> some View {
if UIDevice.current.userInterfaceIdiom == .pad {
self.frame(maxWidth: 600)
} else {
self
}
}
}This works because you’re making a one-time decision based on device type, not changing state.
Wrapping Up: Lessons Learned the Hard Way
Here’s the thing about software development: sometimes the most elegant-looking solution is actually a trap. Conditional ViewBuilders are one of those traps. They appeal to our sense of clean code and declarative programming. They feel like the “SwiftUI way” to do things. But they violate one of SwiftUI’s core principles: view stability and predictable identity.