iosNudge

What are result builders, and how does SwiftUI use them?

HardSwiftSwiftUI 2 min read

TL;DR

A result builder turns a block of plain statements into one value. You write a type with @resultBuilder and static build... methods. The compiler calls them to combine each line. SwiftUI's @ViewBuilder is a result builder. That is why you can list views without commas or return.

What it looks like from outside

VStack {
    Text("Title")
    if showSubtitle {
        Text("Subtitle")
    }
    ForEach(items) { Text($0.name) }
}

There is no array and no return. The @ViewBuilder on the VStack initializer makes this work.

Build a small one

Let us build a list of strings with the same style.

@resultBuilder
enum ListBuilder {
    static func buildExpression(_ line: String) -> [String] { [line] }
    static func buildBlock(_ parts: [String]...) -> [String] { parts.flatMap { $0 } }
    static func buildOptional(_ part: [String]?) -> [String] { part ?? [] }
    static func buildEither(first part: [String]) -> [String] { part }
    static func buildEither(second part: [String]) -> [String] { part }
    static func buildArray(_ parts: [[String]]) -> [String] { parts.flatMap { $0 } }
}

func makeMenu(@ListBuilder _ content: () -> [String]) -> [String] {
    content()
}

let isPro = true
let menu = makeMenu {
    "Home"
    "Search"
    if isPro {
        "Analytics"
    } else {
        "Upgrade"
    }
    for i in 1...2 {
        "Shortcut \(i)"
    }
}
// ["Home", "Search", "Analytics", "Shortcut 1", "Shortcut 2"]

What each method does

MethodUsed for
buildExpressionEach single line. Converts it to a common type
buildBlockCombines all lines in a block. Required
buildOptionalif without else
buildEither(first:) / (second:)if/else and switch
buildArrayfor ... in loops
buildLimitedAvailabilityif #available
buildFinalResultFinal conversion of the whole result
buildPartialBlockCombine lines two at a time (instead of buildBlock)

If you leave out a method, that syntax is not allowed in the builder. For example, without buildArray, a for loop is a compile error.

What the compiler does

Roughly, the closure above becomes:

let a = ListBuilder.buildExpression("Home")
let b = ListBuilder.buildExpression("Search")
let c: [String]
if isPro {
    c = ListBuilder.buildEither(first: ListBuilder.buildBlock(ListBuilder.buildExpression("Analytics")))
} else {
    c = ListBuilder.buildEither(second: ListBuilder.buildBlock(ListBuilder.buildExpression("Upgrade")))
}
// ... loop with buildArray ...
return ListBuilder.buildBlock(a, b, c /*, loop result */)

It is all compile-time code rewriting. There is no runtime magic.

How @ViewBuilder keeps types

@ViewBuilder does not return an array. It keeps exact types:

  • Several views become a TupleView<(Text, Text)>.
  • if/else becomes _ConditionalContent<A, B>.
  • if without else becomes Optional<Text>.

These types are why body returns some View. Writing them by hand would be painful. They also help SwiftUI know the view structure and identity.

Older SwiftUI allowed at most 10 child views in one block. With parameter packs (Swift 5.9), that limit is gone.

Use @ViewBuilder in your own code

struct Card<Content: View>: View {
    @ViewBuilder let content: Content

    var body: some View {
        VStack(alignment: .leading) { content }
            .padding()
            .background(.thinMaterial, in: .rect(cornerRadius: 12))
    }
}

@ViewBuilder
func badge(for count: Int) -> some View {
    if count > 0 {
        Text("\(count)")
    } else {
        EmptyView()
    }
}

Adding @ViewBuilder to a function lets it return different view types from if/else.

Other real uses

  • Regex builder (RegexBuilder module) for readable regular expressions.
  • Swift Charts chart content.
  • HTML or SQL DSLs in server-side Swift.

Common follow-up questions

  • Why can't you write let x = 5 in old builders? Older Swift did not allow declarations in builders. Since Swift 5.4, local let and var are allowed.
  • Why does a guard statement not work in body? Result builders do not support guard, break or continue. Use if/else.
  • What is buildPartialBlock for? It combines items one by one, so the builder supports any number of lines without many buildBlock overloads.
  • Is @ViewBuilder applied to body automatically? Yes. The View protocol marks body with @ViewBuilder.
  • Does a result builder cost performance at run time? No more than writing the calls by hand. The transformation happens at compile time.