肘子的 Swift 记事本

ViewBuilder Research: Mastering Result Builders

Published on

Get weekly handpicked updates on Swift and SwiftUI!

As a developer heavily reliant on SwiftUI, interacting with views is a routine part of my work. Ever since I was introduced to the declarative programming approach of SwiftUI, I have loved the feeling of writing code in this way. However, the more I worked with it, the more problems I encountered. Initially, I attributed many of these issues to supernatural phenomena, thinking that they were probably due to SwiftUI’s immaturity. But as I continued to learn and explore, I discovered that a considerable portion of these problems were due to my own insufficient understanding, and could be improved or avoided altogether.

In this two-part blog series, I will explore ViewBuilder, the tool for building SwiftUI views. The first part will introduce the implementer of ViewBuilder - result builders; the second part will further explore the secrets of SwiftUI views through the imitation of ViewBuilder.

Goals of this article

After reading both articles, I hope to eliminate or alleviate your confusion about the following questions:

  • How to make custom views and methods support ViewBuilder
  • Why complex SwiftUI views are prone to freezing or experiencing compile timeouts in Xcode
  • Why “Extra arguments” error messages appear (only a limited number of views can be placed on the same level)
  • Why caution should be exercised when using AnyView
  • How to avoid using AnyView
  • Why views still contain information about all choice branches regardless of whether they are displayed or not
  • Why the body of the vast majority of official view types is Never
  • The difference between ViewModifier and modifier specific to a particular type of view

What are Result Builders

Introduction

Result builders allow certain functions to implicitly build result values through a series of components, arranging them according to the developer’s specified construction rules. By applying builder translations to function statements, result builders provide the ability to create new domain-specific languages (DSLs) in Swift (with intentional limitations on these builders to preserve the dynamic semantics of the original code).

Compared to commonly used class-based DSLs implemented using point syntax, DSLs created using result builders are simpler, have less redundancy, and are easier to understand (especially when expressing logic with choices, loops, etc.), for example:

Using point syntax (Plot):

Swift
.div(
    .div(
        .forEach(archiveItems.keys.sorted(by: >)) { absoluteMonth in
            .group(
                .ul(
                    .forEach(archiveItems[absoluteMonth]) { item in
                        .li(
                            .a(
                                .href(item.path),
                                .text(item.title)
                            )
                        )
                    }
                ),
                .if( show,
                    .text("hello"),
                  else: .text("wrold")
                 ),
            )
        }
    )
)

Builders created using result builders (swift-html):

Swift
Div {
    Div {
        for i in 0..<100 {
            Ul {
                for item in archiveItems[i] {
                    li {
                        A(item.title)
                            .href(item.path)
                    }
                }
            }
            if show {
                Text("hello")
            } else {
                Text("world")
            }
        }
    }
}

History and Development

Since Swift 5.1, result builders have been hidden within the Swift language with the release of SwiftUI (then named function builder). With the continuous evolution of Swift and SwiftUI, it was eventually officially included in Swift 5.4. Currently, Apple uses this feature heavily in the SwiftUI framework, including not only the most common view builder (ViewBuilder), but also: AccessibilityRotorContentBuilder, CommandsBuilder, LibraryContentBuilder, SceneBuilder, TableColumnBuilder, TableRowBuilder, ToolbarContentBuilder, and WidgetBundleBuilder, among others. Additionally, in the latest Swift proposal, the Regex builder DSL has also appeared. Other developers have also created many third-party libraries using this feature.

Basic Usage

Defining Builder Types

A result builder type must satisfy two basic requirements.

  • It must be annotated with @resultBuilder, indicating that it is intended to be used as a result builder type and allowing it to be used as a custom attribute.
  • It must implement at least one type method named buildBlock.

For example:

Swift
@resultBuilder
struct StringBuilder {
    static func buildBlock(_ parts: String...) -> String {
        parts.map{"⭐️" + $0 + "🌈"}.joined(separator: " ")
    }
}

Through the code above, we have created a result builder with the most basic functionality. The usage method is as follows:

Swift
@StringBuilder
func getStrings() -> String {
    "喜羊羊"
    "美羊羊"
    "灰太狼"
}

// ⭐️喜羊羊🌈 ⭐️美羊羊🌈 ⭐️灰太狼🌈

Provide sufficient subsets of result building methods for builder types

  • buildBlock(_ components: Component...) -> Component

    Used to build the combined result of a statement block. Each result builder must provide at least one concrete implementation.

  • buildOptional(_ component: Component?) -> Component

    Used to handle partial results that may or may not occur in a specific execution. When a result builder provides buildOptional(_:), the translated function can use if statements without else, and also provides support for if let.

  • buildEither(first: Component) -> Component and buildEither(second: Component) -> Component

    Used to establish partial results under different paths of a selection statement. When a result builder provides implementations of these two methods, the translated function can use if statements with else, as well as switch statements.

  • buildArray(_ components: [Component]) -> Component

    Used to collect partial results from all iterations of a loop. When a result builder provides an implementation of buildArray(_:), the translated function can use for...in statements.

  • buildExpression(_ expression: Expression) -> Component

    Allows the result builder to distinguish between Expression types and Component types, providing contextual type information for statement expressions.

  • buildFinalResult(_ component: Component) -> FinalResult

    Used to wrap the final result of buildBlock at the outermost level. For example, the result builder can hide some types that it does not want to expose to the outside world (by converting them into types that can be exposed).

  • buildLimitedAvailability(_ component: Component) -> Component

    Used to convert the partial results generated by buildBlock in a limited environment (such as if #available) into results that are suitable for any environment, to improve API compatibility.

Result builders adopt ad hoc protocols, which means that we can overload the above methods more flexibly. However, in some cases, the translation process of result builders may change their behavior depending on whether the result builder type implements a certain method.

In the following examples, each of the above methods will be explained in detail. In the following text, “result builder” will be referred to as “builder”.

Example 1: AttributedStringBuilder

In this example, we will create a builder for declaring AttributedString. For friends who are not familiar with AttributedString, you can read my other blog post AttributedString: Making Text More Beautiful Than Ever.

The complete code for Example 1 can be found here (Demo1).

After this example, we will be able to declare AttributedString in the following way:

Swift
@AttributedStringBuilder
var text: Text {
    "_*Hello*_"
        .localized()
        .color(.green)

    if case .male = gender {
        " Boy!"
            .color(.blue)
            .bold()

    } else {
        " Girl!"
            .color(.pink)
            .bold()
    }
}

https://cdn.fatbobman.com/image-20220331202541444.png

Creating Builder Types

Swift
@resultBuilder
public enum AttributedStringBuilder {
    // Corresponding to the case where no component is used in the block
    public static func buildBlock() -> AttributedString {
        .init("")
    }

    // Corresponding to the case where n components (n is a positive integer) are used in the block
    public static func buildBlock(_ components: AttributedString...) -> AttributedString {
        components.reduce(into: AttributedString("")) { result, next in
            result.append(next)
        }
    }
}

First, we create a builder named AttributedStringBuilder and implement two buildBlock methods for it. The builder will automatically select the corresponding method during translation.

Now, any number of components (AttributedString) can be provided for the block, and buildBlock will convert them into the specified result (AttributedString).

When implementing the buildBlock method, the components and the return data type of the block should be defined according to actual requirements, and need not be consistent.

Translating Block with Builder

The builder can be called explicitly, for example:

Swift
@AttributedStringBuilder // Marked explicitly
var myFirstText: AttributedString {
    AttributedString("Hello")
    AttributedString("World")
}
// "HelloWorld"

@AttributedStringBuilder
func mySecondText() -> AttributedString {} // Empty block will call `buildBlock() -> AttributedString`
// ""

The builder can also be called implicitly:

Swift
// Marked on the API side
func generateText(@AttributedStringBuilder _ content: () -> AttributedString) -> Text {
    Text(content())
}

// Implicitly called on the client side
VStack {
    generateText {
        AttributedString("Hello")
        AttributedString(" World")
    }
}

struct MyTest {
    var content: AttributedString
    // Marked in the constructor
    init(@AttributedStringBuilder _ content: () -> AttributedString) {
        self.content = content()
    }
}

// Implicitly called
let attributedString = MyTest {
    AttributedString("ABC")
    AttributedString("BBC")
}.content

Regardless of the method used, if the return keyword is used at the end of the block to return the result, the builder will automatically ignore the translation process. For example:

Swift
@AttributedStringBuilder
var myFirstText: AttributedString {
    AttributedString("Hello") // This statement will be ignored
    return AttributedString("World") // Only "World" will be returned
}
// "World"

To use syntax not supported by the builder in the block, developers may try to use return to return a result value. This should be avoided because developers will lose the flexibility of using the builder to translate.

The following code has completely different states when translated with the builder and when not translated with the builder:

Swift
// Builder automatically translates and the block only returns the final synthesized result. The code can be executed normally.
@ViewBuilder
func blockTest() -> some View {
    if name.isEmpty {
        Text("Hello anonymous!")
    } else {
        Rectangle()
            .overlay(Text("Hello \\(name)"))
    }
}

// The builder's translation behavior is ignored due to the `return` keyword. The two branches of the selection statement in the block return two different types, which does not meet the requirement of returning the same type (`some View`). Compilation fails.
@ViewBuilder
func blockTest() -> some View {
    if name.isEmpty {
        return Text("Hello anonymous!")
    } else {
        return Rectangle()
            .overlay(Text("Hello \\(name)"))
    }
}

To perform other tasks without affecting the builder’s translation process, call the code in the block in the following way:

Swift
@AttributedStringBuilder
var myFirstText: AttributedString {
    let _ = print("update") // Declaration statements will not affect the builder's translation.
    AttributedString("Hello")
    AttributedString("World")
}

Adding modifiers

Before continuing to improve other methods of the builder, let’s add some ViewModifier-like functionality to AttributedStringBuilder so that we can easily modify the style of the AttributedString similar to SwiftUI. Add the following code:

Swift
public extension AttributedString {
    func color(_ color: Color) -> AttributedString {
        then {
            $0.foregroundColor = color
        }
    }

    func bold() -> AttributedString {
        return then {
            if var inlinePresentationIntent = $0.inlinePresentationIntent {
                var container = AttributeContainer()
                inlinePresentationIntent.insert(.stronglyEmphasized)
                container.inlinePresentationIntent = inlinePresentationIntent
                let _ = $0.mergeAttributes(container)
            } else {
                $0.inlinePresentationIntent = .stronglyEmphasized
            }
        }
    }

    func italic() -> AttributedString {
        return then {
            if var inlinePresentationIntent = $0.inlinePresentationIntent {
                var container = AttributeContainer()
                inlinePresentationIntent.insert(.emphasized)
                container.inlinePresentationIntent = inlinePresentationIntent
                let _ = $0.mergeAttributes(container)
            } else {
                $0.inlinePresentationIntent = .emphasized
            }
        }
    }

    func then(_ perform: (inout Self) -> Void) -> Self {
        var result = self
        perform(&result)
        return result
    }
}

Since AttributedString is a value type, we need to create a new copy and modify its properties. The usage of modifier is as follows:

Swift
@AttributedStringBuilder
var myFirstText: AttributedString {
    AttributedString("Hello")
         .color(.red)
    AttributedString("World")
         .color(.blue)
         .bold()
}

Although I have only written a small amount of code, I am beginning to feel the sense of DSL.

Simplified expression

Because the block can only receive a specific type of component (AttributedString), the prefix of AttributedString type needs to be added to each line of code, which leads to a large workload and also affects the reading experience. This process can be simplified by using buildExpression.

Add the following code:

Swift
public static func buildExpression(_ string: String) -> AttributedString {
    AttributedString(string)
}

After adding the above code, we directly replace AttributedString with String, which will be first converted to AttributedString by the builder, and then passed to buildBlock.

Swift
@AttributedStringBuilder
var myFirstText: AttributedString {
    "Hello"
    "World"
}

However, now we are facing a new issue - unable to mix String and AttributedString in a block. This is because, if we don’t provide a custom implementation of buildExpression, the builder will infer the type of the component as AttributedString. Once we provide a custom implementation of buildExpression, the builder will no longer use automatic inference. The solution is to also create a buildExpression for AttributedString:

Swift
public static func buildExpression(_ attributedString: AttributedString) -> AttributedString {
    attributedString
}

Now you can mix and use both in a block.

Swift
@AttributedStringBuilder
var myFirstText: AttributedString {
    "Hello"
    AttributedString("World")
}

Another issue is that we cannot directly use the modifiers we created earlier under String because the previous modifiers were specified for AttributedString. The dot syntax can only be used for methods that are specific to String. There are two solutions to this problem: first, extend String to convert it to AttributedString, and second, add a modifier converter to String. For now, we will use the more cumbersome second option:

Swift
public extension String {
    func color(_ color: Color) -> AttributedString {
        AttributedString(self)
            .color(color)
    }

    func bold() -> AttributedString {
        AttributedString(self)
            .bold()
    }

    func italic() -> AttributedString {
        AttributedString(self)
            .italic()
    }
}

Now, we can make declarations quickly and clearly.

Swift
@AttributedStringBuilder
var myFirstText: AttributedString {
    "Hello"
         .color(.red)
    "World"
         .color(.blue)
         .bold()
}

AttributedString provides support for localized strings and some Markdown syntax, but only for AttributedString constructed through the String.LocalizationValue type. You can solve this problem by following these steps:

Swift
public extension String {
    func localized() -> AttributedString {
        .init(localized: LocalizationValue(self))
    }
}

Convert the string to an AttributedString constructed using String.LocalizationValue, which can be directly used with modifiers written for AttributedString (you can also use a similar approach for strings to avoid repeating modifiers).

Swift
@AttributedStringBuilder
var myFirstText: AttributedString {
    "Hello"
         .color(.red)
    "~**World**~"
         .localized()
         .color(.blue)
         //.bold()    Use Markdown syntax to describe bold text. When using Markdown syntax, setting inlinePresentationIntent directly will cause conflicts.
}

https://cdn.fatbobman.com/image-20220401090042983.png

Logic of Builder Translation

Understanding how builders are translated will help with future learning.

Swift
@AttributedStringBuilder
var myFirstText: AttributedString {
    "Hello"
    AttributedString("World")
         .color(.red)
}

When the builder processes the above code, it will be translated into the following code:

Swift
var myFirstText: AttributedString {
    let _a = AttributedStringBuilder.buildExpression("Hello")  // Calls buildExpression for String
    let _b = AttributedStringBuilder.buildExpression(AttributedString("World").color(.red)) // Calls buildExpression for AttributedString
    return AttributedStringBuilder.buildBlock(_a,_b) // Calls buildBlock with multiple parameters
}

The two blocks of code above are completely equivalent, and Swift automatically handles this process behind the scenes.

When learning to create builders, it is helpful to add print commands within the builder method’s implementation to better understand the timing of each method call.

Add support for if statements without else

When processing if statements with and without else, result builders use completely different internal mechanisms. To support if statements without else, only the following method needs to be implemented:

Swift
public static func buildOptional(_ component: AttributedString?) -> AttributedString {
    component ?? .init("")
}

When the builder calls this method, it passes different parameters depending on whether the condition is met. If the condition is not met, nil is passed. The usage method is:

Swift
var show = true
@AttributedStringBuilder
var myFirstText: AttributedString {
    "Hello"
    if show {
        "World"
    }
}

After adding the implementation of buildOptional, the builder will also support the if let syntax, for example:

Swift
var name:String? = "fat"
@AttributedStringBuilder
var myFirstText: AttributedString {
    "Hello"
    if let name = name {
        " \(name)"
    }
}

The translation code corresponding to buildOptional is:

Swift
// Logic corresponding to the if statement above
var myFirstText: AttributedString {
    let _a = AttributedStringBuilder.buildExpression("Hello")
    var vCase0: AttributedString?
    if show == true {
        vCase0 = AttributedStringBuilder.buildExpression("World")
    }
    let _b = AttributedStringBuilder.buildOptional(vCase0)
    return AttributedStringBuilder.buildBlock(_a, _b)
}

// Logic corresponding to the if let statement above
var myFirstText: AttributedString {
    let _a = AttributedStringBuilder.buildExpression("Hello")
    var vCase0:AttributedString?
    if let name = name {
        vCase0 = AttributedStringBuilder.buildExpression(name)
    }
    let _b = AttributedStringBuilder.buildOptional(vCase0)
    return AttributedStringBuilder.buildBlock(_a,_b)
}

This is why implementing buildOptional is enough to support both if (without else) and if let statements.

Adding support for multi-branch selection

For if else and switch syntax, you need to implement two methods: buildEither(first:) and buildEither(second:):

Swift
// Called for the branch where the condition is true (left branch)
public static func buildEither(first component: AttributedString) -> AttributedString {
    component
}

// Called for the branch where the condition is false (right branch)
public static func buildEither(second component: AttributedString) -> AttributedString {
    component
}

Usage is as follows:

Swift
var show = true
@AttributedStringBuilder
var myFirstText: AttributedString {
    if show {
        "Hello"
    } else {
        "World"
    }
}

The corresponding translated code is:

Swift
var myFirstText: AttributedString {
    let vMerged: AttributedString
    if show {
        vMerged = AttributedStringBuilder.buildEither(first: AttributedStringBuilder.buildExpression("Hello"))
    } else {
        vMerged = AttributedStringBuilder.buildEither(second: AttributedStringBuilder.buildExpression("World"))
    }
    return AttributedStringBuilder.buildBlock(vMerged)
}

When an else statement is present, the builder will produce a binary tree during translation, with each result assigned to one of its leaves. For the non-else part of a branch in an if else, the builder will still handle it using buildOptional, for example:

Swift
var show = true
var name = "fatbobman"
@AttributedStringBuilder
var myFirstText: Text {
    if show {
        "Hello"
    } else if name.count > 5 {
        name
    }
}

Translated text goes here

Swift
// Translated code
var myFirstText: AttributedString {
    let vMerged: AttributedString
    if show {
        vMerged = AttributedStringBuilder.buildEither(first: AttributedStringBuilder.buildExpression("Hello"))
    } else {
        // First handle the case where else is not present using buildOptional
        var vCase0: AttributedString?
        if name.count > 5 {
            vCase0 = AttributedStringBuilder.buildExpression(name)
        }
        let _a = AttributedStringBuilder.buildOptional(vCase0)
        // Sum up the right branch to vMerged
        vMerged = AttributedStringBuilder.buildEither(second: _a)
    }
    return AttributedStringBuilder.buildBlock(vMerged)
}

Support for switch is also done using the same approach. The builder applies the above rules recursively during translation.

You may wonder why the implementation of buildEither is so simple and has little significance. Many people had this question during the result builders proposal process. In fact, Swift’s design has its quite appropriate application area. In the next article ViewBuilder Research: Creating a ViewBuilder imitation, we will see how ViewBuilder saves the type information of all branches through buildEither.

Support for…in loop

The for...in statement collects all the results of the iterations into an array and passes them to buildArray. Providing an implementation for buildArray enables the builder to support loop statements.

Swift
// In this example, we simply concatenate all the iteration results to generate an AttributedString
public static func buildArray(_ components: [AttributedString]) -> AttributedString {
    components.reduce(into: AttributedString("")) { result, next in
        result.append(next)
    }
}

Usage:

Swift
@AttributedStringBuilder
func test(count: Int) -> Text {
    for i in 0..<count {
        " \\(i) "
    }
}

Translated code:

Swift
func test(count: Int) -> AttributedString {
    var vArray = [AttributedString]()
    for i in 0..<count {
        vArray.append(AttributedStringBuilder.buildExpression(" \(i)"))
    }
    let _a = AttributedStringBuilder.buildArray(vArray)
    return AttributedStringBuilder.buildBlock(_a)
}

Improving Version Compatibility

If the implementation of buildLimitedAvailability is provided, the builder provides support for API availability checks (such as if #available(..)). This is common in SwiftUI, for example, where certain Views or modifiers are only supported on newer platforms and we need to provide alternative content for unsupported platforms.

Swift
public static func buildLimitedAvailability(_ component: AttributedString) -> AttributedString {
    component
}

This method does not exist independently and is used together with buildOptional or buildEither. When the API availability check meets the conditions, the result builders will call this implementation. In SwiftUI, AnyView is used to erase types for type stability.

Usage:

Swift
// Create a method that is not supported on the current platform
@available(macOS 13.0, iOS 16.0,*)
public extension AttributedString {
    func futureMethod() -> AttributedString {
        self
    }
}

@AttributedStringBuilder
var text: AttributedString {
    if #available(macOS 13.0, iOS 16.0, *) {
        AttributedString("Hello macOS 13")
            .futureMethod()
    } else {
        AttributedString("Hi Monterey")
    }
}

The corresponding translation logic is:

Swift
var text: AttributedString {
    let vMerge: AttributedString
    if #available(macOS 13.0, iOS 16.0, *) {
        let _temp = AttributedStringBuilder
            .buildLimitedAvailability(
                AttributedStringBuilder.buildExpression(AttributedString("Hello macOS 13").futureMethod())
            )
        vMerge = AttributedStringBuilder.buildEither(first: _temp)
    } else {
        let _temp = AttributedStringBuilder.buildExpression(AttributedString("Hi Monterey"))
        vMerge = AttributedStringBuilder.buildEither(second: _temp)
    }
    return = AttributedStringBuilder.buildBlock(vMerge)
}

Wrapping up the Result

If we provide an implementation of buildFinalResult, the builder will use it to transform the result again at the end of the translation process, and the final result will be the return value of buildFinalResult.

In most cases, we don’t need to implement buildFinalResult, and the builder will use the return value of buildBlock as the final result.

Swift
public static func buildFinalResult(_ component: AttributedString) -> Text {
    Text(component)
}

For demonstration purposes, in this example we will convert AttributedString to Text using the buildFinalResult method. Usage:

Swift
@AttributedStringBuilder
var text: Text {  // The final result type has been translated to Text
    "Hello world"
}
Swift
var text: Text {
    let _a = AttributedStringBuilder.buildExpression("Hello world")
    let _blockResult = AttributedStringBuilder.buildBlock(_a)
    return AttributedStringBuilder.buildFinalResult(_blockResult)
}

So far, we have achieved the goal set at the beginning of this section. However, the current implementation still cannot provide us with the possibility of creating various containers, such as SwiftUI, which will be solved in Example 2.

Example 2: AttributedTextBuilder

The complete code for Example 2 can be obtained here (Demo2).

Insufficiencies of Version 1

  • Can only add modifiers to components (AttributedString, String) one by one, cannot be configured uniformly
  • Cannot be dynamically laid out, buildBlock connects all content together, and line breaks can only be achieved by adding \\n separately

Using Protocols Instead of Types

The main reason for the above problems is that the component in the buildBlock above is a specific AttributedString type, which limits our ability to create containers (other components). To solve the above shortcomings, we can refer to the solution of SwiftUI View, use protocols instead of specific types, and make AttributedString also conform to the protocol.

First, we will create a new protocol - AttributedText:

Swift
public protocol AttributedText {
    var content: AttributedString { get }
    init(_ attributed: AttributedString)
}

extension AttributedString: AttributedText {
    public var content: AttributedString {
        self
    }

    public init(_ attributed: AttributedString) {
        self = attributed
    }
}

Make AttributedString compliant with the protocol:

Swift
extension AttributedString: AttributedText {
    public var content: AttributedString {
        self
    }

    public init(_ attributed: AttributedString) {
        self = attributed
    }
}

Creating a new builder - AttributedTextBuilder, the biggest change is to change the type of all components to AttributedText.

Swift
@resultBuilder
public enum AttributedTextBuilder {
    public static func buildBlock() -> AttributedString {
        AttributedString("")
    }

    public static func buildBlock(_ components: AttributedText...) -> AttributedString {
        let result = components.map { $0.content }.reduce(into: AttributedString("")) { result, next in
            result.append(next)
        }
        return result.content
    }

    public static func buildExpression(_ attributedString: AttributedText) -> AttributedString {
        attributedString.content
    }

    public static func buildExpression(_ string: String) -> AttributedString {
        AttributedString(string)
    }

    public static func buildOptional(_ component: AttributedText?) -> AttributedString {
        component?.content ?? .init("")
    }

    public static func buildEither(first component: AttributedText) -> AttributedString {
        component.content
    }

    public static func buildEither(second component: AttributedText) -> AttributedString {
        component.content
    }

    public static func buildArray(_ components: [AttributedText]) -> AttributedString {
        let result = components.map { $0.content }.reduce(into: AttributedString("")) { result, next in
            result.append(next)
        }
        return result.content
    }

    public static func buildLimitedAvailability(_ component: AttributedText) -> AttributedString {
        .init("")
    }
}

Creating a modifier for AttributedText:

Swift
public extension AttributedText {
    func transform(_ perform: (inout AttributedString) -> Void) -> Self {
        var attributedString = self.content
        perform(&attributedString)
        return Self(attributedString)
    }

    func color(_ color: Color) -> AttributedText {
        transform {
            $0 = $0.color(color)
        }
    }

    func bold() -> AttributedText {
        transform {
            $0 = $0.bold()
        }
    }

    func italic() -> AttributedText {
        transform {
            $0 = $0.italic()
        }
    }
}

With this, we now have the ability to create custom view controls similar to those in SwiftUI.

Creating a Container

A Container is similar to a Group in SwiftUI, it does not change the layout and makes it easy to apply modifiers uniformly to the elements inside the Container.

Swift
public struct Container: AttributedText {
    public var content: AttributedString

    public init(_ attributed: AttributedString) {
        content = attributed
    }

    public init(@AttributedTextBuilder _ attributedText: () -> AttributedText) {
        self.content = attributedText().content
    }
}

As the Container also conforms to the AttributedText protocol, it will be treated as a component and can have modifiers applied to it. To use:

Swift
@AttributedTextBuilder
var attributedText: AttributedText {
    Container {
        "Hello "
            .localized()
            .color(.red)
            .bold()

        "~World~"
            .localized()
    }
    .color(.green)
    .italic()
}

When you execute the code above at this point, you will find that the previously red “Hello” has also turned green, which is not what we expected. In SwiftUI, the inner settings take precedence over the outer settings. To solve this problem, we need to make some modifications to the modifier of AttributedString.

Swift
public extension AttributedString {
    func color(_ color: Color) -> AttributedString {
        var container = AttributeContainer()
        container.foregroundColor = color
        return then {
            for run in $0.runs {
                $0[run.range].mergeAttributes(container, mergePolicy: .keepCurrent)
            }
        }
    }

    func bold() -> AttributedString {
        return then {
            for run in $0.runs {
                if var inlinePresentationIntent = run.inlinePresentationIntent {
                    var container = AttributeContainer()
                    inlinePresentationIntent.insert(.stronglyEmphasized)
                    container.inlinePresentationIntent = inlinePresentationIntent
                    let _ = $0[run.range].mergeAttributes(container)
                } else {
                    $0[run.range].inlinePresentationIntent = .stronglyEmphasized
                }
            }
        }
    }

    func italic() -> AttributedString {
        return then {
            for run in $0.runs {
                if var inlinePresentationIntent = run.inlinePresentationIntent {
                    var container = AttributeContainer()
                    inlinePresentationIntent.insert(.emphasized)
                    container.inlinePresentationIntent = inlinePresentationIntent
                    let _ = $0[run.range].mergeAttributes(container)
                } else {
                    $0[run.range].inlinePresentationIntent = .emphasized
                }
            }
        }
    }

    func then(_ perform: (inout Self) -> Void) -> Self {
        var result = self
        perform(&result)
        return result
    }
}

By traversing the run views of AttributedString, we have implemented the rule that inner attribute settings take precedence over outer attribute settings.

Creating Paragraph

A Paragraph creates line breaks at the beginning and end of its content.

Swift
public struct Paragraph: AttributedText {
    public var content: AttributedString
    public init(_ attributed: AttributedString) {
        content = attributed
    }

    public init(@AttributedTextBuilder _ attributedText: () -> AttributedText) {
        self.content = "\n" + attributedText().content + "\n"
    }
}

By treating protocols as components, more possibilities are provided for builders.

Improvements and shortcomings of result builders

Completed Improvements

Starting from Swift 5.1, result builders have undergone several versions of improvements, adding some features and also solving some performance issues:

  • Added buildOptional and removed buildIf, retaining support for if (excluding else) while adding support for if let
  • Starting from SwiftUI 2.0, the switch keyword is now supported
  • Modified the syntax translation mechanism for buildBlock in Swift 5.1, prohibiting backward propagation of parameter types. This was the primary reason why early SwiftUI view code often resulted in compilation errors such as “expression too complex to be solved in a reasonable time”.

Current Limitations

  • Lack of partial selection and control capabilities, such as: guard, break, continue.

  • Lack of the ability to limit naming within the builder context.

    For DSL, it is common to introduce shorthand words. Currently, when creating a component for the builder, only new data types (such as Container, Paragraph) or global functions can be used. It is hoped that in the future, these names can be limited only within the context and not introduced into the global scope.

Follow-up

The basic functionality of Result Builders is very simple. In the previous section, we only had a few lines of code related to builder methods. However, creating a useful and easy-to-use DSL requires a huge amount of work, and developers should weigh the pros and cons of using Result Builders based on their actual needs.

In the next section, we will try to replicate a builder that is consistent with the basic form of ViewBuilder, and we believe that the replication process can give you a deeper understanding of ViewBuilder and SwiftUI views.

I'm really looking forward to hearing your thoughts! Please Leave Your Comments Below to share your views and insights.

Fatbobman(东坡肘子)

I'm passionate about life and sharing knowledge. My blog focuses on Swift, SwiftUI, Core Data, and Swift Data. Follow my social media for the latest updates.

You can support me in the following ways