SwiftUI @ViewBuilder: When You Actually Need It
Understanding when @ViewBuilder is required vs optional in SwiftUI computed properties.
Your SwiftUI code compiles without @ViewBuilder. Should you keep it?
The Core Rule
@ViewBuilder is optional when returning a single view expression. It’s required when returning multiple views or using certain syntax patterns.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Works without @ViewBuilder (single expression)
var headerView: some View {
Group {
switch headerType {
case .title: Text("Header")
case .none: EmptyView()
}
}.frame(height: 80)
}
// Requires @ViewBuilder (multiple expressions)
@ViewBuilder
var headerView: some View {
Text("Header")
Divider() // Compilation error without @ViewBuilder
}
What @ViewBuilder Does
Transforms SwiftUI’s declarative syntax into type-erased view hierarchies. Without it, you’re limited to standard Swift return semantics.
Enables Multi-Statement Bodies
1
2
3
4
5
6
@ViewBuilder
var content: some View {
Text("Title")
Text("Subtitle")
Divider()
}
Supports Transparent Control Flow
1
2
3
4
5
6
7
8
@ViewBuilder
var status: some View {
if isLoading {
ProgressView()
} else {
Text(data)
}
}
Without @ViewBuilder, you need explicit containers:
1
2
3
4
5
6
7
8
9
var status: some View {
Group {
if isLoading {
ProgressView()
} else {
Text(data)
}
}
}
Your Specific Case
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public var headerView: some View {
Group {
switch self {
case .titleOnly(let config):
HStack {
Spacer()
Text(config.title)
Spacer()
}
case .withCloseButton(let config):
ZStack { /* ... */ }
case .none:
Spacer()
}
}.frame(height: 80)
}
This compiles without @ViewBuilder because:
Groupwraps all switch cases.frame()creates single chained expression- Final return type is
some Viewfrom single expression chain
When You Must Use @ViewBuilder
Function Parameters
1
2
3
4
5
6
7
8
9
10
11
12
13
struct Container<Content: View>: View {
@ViewBuilder let content: () -> Content
var body: some View {
VStack { content() }
}
}
// Usage
Container {
Text("Line 1")
Text("Line 2")
}
Without @ViewBuilder on the parameter, users can’t pass multiple views.
Custom Container Views
1
2
3
4
5
6
7
8
9
10
11
12
struct Card<Header: View, Content: View>: View {
@ViewBuilder let header: () -> Header
@ViewBuilder let content: () -> Content
var body: some View {
VStack {
header()
Divider()
content()
}
}
}
Conditional Multi-View Returns
1
2
3
4
5
6
7
8
9
@ViewBuilder
func userStatus() -> some View {
if user.isPremium {
Image(systemName: "star.fill")
Text("Premium")
} else {
Text("Free")
}
}
When It’s Optional
Single Expression Returns
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// No @ViewBuilder needed
var simple: some View {
Text("Hello")
}
var chained: some View {
Text("Hello")
.font(.title)
.foregroundColor(.blue)
}
var wrapped: some View {
VStack {
Text("Inside container")
}
}
Control Flow with Containers
1
2
3
4
5
6
7
8
9
10
// @ViewBuilder optional (Group wraps everything)
var conditional: some View {
Group {
if condition {
Text("A")
} else {
Text("B")
}
}
}
Best Practice
Keep @ViewBuilder even when optional for:
- Future flexibility: Removing containers later won’t break compilation
- Standard convention: Signals view-building intent to other developers
- Zero cost: No performance or compile-time penalty
- Consistency: All computed view properties follow same pattern
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Recommended approach
@ViewBuilder
public var headerView: some View {
switch self {
case .titleOnly(let config):
HStack {
Spacer()
Text(config.title)
Spacer()
}
case .withCloseButton(let config):
ZStack { /* ... */ }
case .none:
EmptyView()
}
}
Notice: Removed Group wrapper since @ViewBuilder handles the switch directly.
Performance Note
@ViewBuilder adds zero runtime overhead. It’s a compile-time transformation that generates type-specific view builders. Using or omitting it doesn’t affect app performance.
Remove @ViewBuilder When
You’re defining a computed property that genuinely returns a concrete type:
1
2
3
4
// No @ViewBuilder - returns concrete VStack
var container: VStack<Text> {
VStack { Text("Fixed type") }
}
This loses type erasure benefits but gains type safety for specific scenarios.
☕ Support My Work
If you found this post helpful and want to support more content like this, you can buy me a coffee!
Your support helps me continue creating useful articles and tips for fellow developers. Thank you! 🙏