Post

SwiftUI @ViewBuilder: When You Actually Need It

Understanding when @ViewBuilder is required vs optional in SwiftUI computed properties.

SwiftUI @ViewBuilder: When You Actually Need It

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:

  1. Group wraps all switch cases
  2. .frame() creates single chained expression
  3. Final return type is some View from 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:

  1. Future flexibility: Removing containers later won’t break compilation
  2. Standard convention: Signals view-building intent to other developers
  3. Zero cost: No performance or compile-time penalty
  4. 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! 🙏

This post is licensed under CC BY 4.0 by the author.