肘子的 Swift 记事本

ViewBuilder Research: Creating a ViewBuilder Imitation

Published on

Get weekly handpicked updates on Swift and SwiftUI!

In ViewBuilder Research: Mastering Result Builders, we provided a detailed introduction to result builders. In this article, we will explore more secrets behind SwiftUI views by imitating ViewBuilder.

Information Provided by Views

In this article, “views” refer to various types that conform to the SwiftUI View protocol.

Developers use the basic view types provided by the SwiftUI framework to create custom views, which provide the following information to SwiftUI:

  • Interface Design

    Developers describe the user interface in a lightweight manner. SwiftUI reads these descriptions from the body property of the views created by developers at the appropriate time and draws them.

  • Dependencies

    We often say that views are functions of state. For a single view, its state is composed of all the dependencies related to it. The dependencies of a view include: basic view attributes (which do not need to conform to the DynamicProperty protocol), properties that drive view updates (which conform to the DynamicProperty protocol, such as @State, @Environment, etc.), and elements such as onReceive.

  • View Types

    SwiftUI distinguishes between views based on their type and specific location in the view hierarchy (view tree). For SwiftUI, the type of a view is one of the most important pieces of information.

  • Other

    Some lightweight code related to the current view.

How SwiftUI handles views

In SwiftUI, the process of loading, responding to state changes, and drawing on the screen generally goes through the following steps:

  • Starting from the root view, instantiate views one by one along a specific branch of the view hierarchy structure (based on the initial state), until all the current display needs are met.
  • The instantiated view values (structural values, not body values) will be saved in SwiftUI’s managed data pool.
  • Create a dependency graph in the AttributeGraph data pool that corresponds to the currently displayed view tree based on the view’s dependency information, and monitor changes in dependencies.
  • Layout and render based on the body attribute of the view’s value in the SwiftUI data pool or the specific type method of the view type (not public).
  • When the dependency data changes due to user or system behavior, SwiftUI will locate the view that needs to be re-evaluated based on the dependency graph.
  • Starting from the view that needs to be re-evaluated as the root, instantiate the view type one by one according to the current state of the view hierarchy structure (until all display requirements are met).
  • Remove the values of views that are no longer needed for layout and rendering from the SwiftUI data pool, and add new view values to the data pool.
  • For views that still need to be displayed but have changed view values, replace the original view values with new view values.
  • Reorganize the dependency graph and draw the newly added and changed views.
  • Repeat the process cyclically.

Imitating ViewBuilder

ViewBuilder helps developers declare views in a concise, clear, and readable way. If you are not familiar with its implementation principle, please read ViewBuilder Research: Mastering Result Builders.

The View protocol, ViewBuilder, and other content imitated in this article only involve the tip of the iceberg of the SwiftUI framework. You can get all the code of this article here.

Creating View Protocol

Since views are types that conform to the View protocol, we first need to define our own View protocol.

Swift
import Foundation

public protocol View {
    associatedtype Body: View
    var body: Self.Body { get }
}

The public interface of the View protocol is very simple, and developer-defined view types only need to provide a body property that conforms to the View protocol. How does SwiftUI accomplish the complex view processing using such a simple interface? The answer is: it can’t!

The SwiftUI View protocol also has three unpublished interfaces, which are:

Swift
static func _makeView(view: SwiftUI._GraphValue<Self>, inputs: SwiftUI._ViewInputs) -> SwiftUI._ViewOutputs
static func _makeViewList(view: SwiftUI._GraphValue<Self>, inputs: SwiftUI._ViewListInputs) -> SwiftUI._ViewListOutputs
static func _viewListCount(inputs: SwiftUI._ViewListCountInputs) -> Swift.Int?

A fully functional view type should provide all the definitions required above. Currently, it is not possible to implement these private methods on our own, and we can only use the default implementation provided by SwiftUI. However, the basic view types provided by the SwiftUI framework make full use of these interfaces to meet their different needs.

If you look at the documentation for SwiftUI, you will see that the body type of the basic view types it provides (such as Text, EmptyView, Group, etc.) is mostly Never, which is quite different from custom view types developed by developers.

These views that use Never as their body property type are mainly divided into several categories:

  • Define layout

    For example: VStack, HStack, Spacer

  • Communicate with underlying drawing elements

    For example: Text, TextField, UIViewControllerRepresentable

  • Type placeholders and erasure

    For example: EmptyView, AnyView

  • Wrapping

    For example: ModifiedContent, Group

  • Logical description:

    For example: _ConditionalContent

When SwiftUI encounters these view types, it does not attempt to access their body property content (Never is untouchable), but instead processes them according to their specific logic.

Therefore, we need to make Never conform to the View protocol to continue the work below:

Swift
extension Never: View {
    public typealias Body = Never
    public var body: Never { fatalError() }
}

Creating EmptyView

With the View protocol in place, we will create our first basic view - EmptyView. As the name suggests, EmptyView is a view that does nothing, it is just an empty view:

Swift
public struct EmptyView: View {
    public typealias Body = Never
    public var body: Never { fatalError() }
    public init() {}
}

In this article, our main goal is to make the logic of ViewBuilder run, and only need to make the view type meet the public requirements of the View protocol.

Debug Tool

In order to better compare whether the parsing of our custom ViewBuilder and SwiftUI’s official ViewBuilder for view types is consistent in the following text, we also need to prepare a view extension method (valid for both original and replica):

Swift
public extension View {
    func debug() -> some View {
        let _ = print(Mirror(reflecting: self).subjectType)
        return self
    }
}

Example of printing view type information with debug in SwiftUI:

Swift
struct ContentView:View {
    var body: some View {
        Group {
            Text("Hello")
            Text("World")
        }
        .debug()
    }
}

// Group<TupleView<(Text, Text)>>

The printed content will show us the current view hierarchy, and our custom ViewBuilder should be able to generate almost the same information as SwiftUI’s ViewBuilder.

Creating a ViewBuilder

For a result builder, at least one implementation of buildBlock should be provided.

Swift
@resultBuilder
public enum ViewBuilder {
    // For an empty closure, set the return type to EmptyView
    public static func buildBlock() -> EmptyView {
        EmptyView()
    }
}

Congratulations, we have now completed the most basic creation work for ViewBuilder.

Use it to parse the first view:

Swift
@ViewBuilder
func myFirstView() -> some View {}

View type information can be viewed through debugging:

Swift
print(type(of: myFirstView()))
// EmptyView

Now we can use ViewBuilder in the previous View protocol and debug extension. Update them as follows:

Swift
public protocol View {
    associatedtype Body: View
    @ViewBuilder var body: Self.Body { get }  // 添加了 @ViewBuilder
}

public extension View {
    @ViewBuilder
    func debug() -> some View {
        let _ = print(Mirror(reflecting: self).subjectType)
        self
    }
}

Creating more buildBlocks

In the previous section, we provided only one implementation of buildBlock that supports an empty closure (0 components). The following code will allow us to add a component (view) in the view declaration:

Swift
// For a single component, buildBlock will preserve its original type
public static func buildBlock<Content>(_ content: Content) -> Content where Content: View {
    content
}

Now we can add a component in the closure:

Swift
struct ContentView:View {
    var body: some View {
        EmptyView()
    }
}

ContentView().body.debug() // Because our view cannot be loaded, we need to use this method to obtain the type information of the view body
// EmptyView

What happens if we now add two EmptyViews in the closure?

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

As only buildBlocks supporting 0 and 1 component are currently defined, the compiler will prompt us that it cannot find the corresponding buildBlock implementation.

Since the View protocol uses associated types, we cannot handle an arbitrary number of components using an array like we did with AttributedTextBuilder in the previous article. SwiftUI handles different numbers of components by creating multiple overloaded buildBlocks that return a TupleView.

Create a basic view type TupleView:

Swift
public struct TupleView<T>: View {
    var content: T
    public var body: Never { fatalError() }
    public init(_ content: T) {
        self.content = content
    }
}

Creating more buildBlock:

Swift
// Return TupleView<> for 2 to 10 components
public extension ViewBuilder {
    static func buildBlock<C0, C1>(_ c0: C0, _ c1: C1) -> TupleView<(C0, C1)> where C0: View, C1: View {
        TupleView((c0, c1))
    }

    static func buildBlock<C0, C1, C2>(_ c0: C0, _ c1: C1, _ c2: C2) -> TupleView<(C0, C1, C2)> where C0: View, C1: View, C2: View {
        .init((c0, c1, c2))
    }

    static func buildBlock<C0, C1, C2, C3>(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3) -> TupleView<(C0, C1, C2, C3)> where C0: View, C1: View, C2: View, C3: View {
        .init((c0, c1, c2, c3))
    }

    // ...
}

Currently, SwiftUI only supports up to 10 components for buildBlock overloads, so we can only declare up to 10 views in the same level of a view closure. If you want SwiftUI to support more components, you can simply create more overloads.

Swift
// Now we can declare more views in the closure
struct ContentView:View {
    var body: some View {
        EmptyView()
        EmptyView()
    }
}

ContentView().body.debug()
// TupleView<(EmptyView, EmptyView)>

There is currently a proposal under review for result builders which adds a buildPartialBlock method. If this proposal is accepted, only implementing buildPartialBlock(first: Component) -> Component and buildPartialBlock(accumulated: Component, next: Component) -> Component would be able to handle any number of components.

Creating More Basic Views

Currently, we have created two types of basic views: EmptyView and TupleView. Next, we will create more basic view types to prepare for future use.

  • Group
Swift
public struct Group<Content>: View {
    var content: Content
    public var body: Never { fatalError() }
    public init(@ViewBuilder _ content: () -> Content) {
        self.content = content()
    }
}

struct ContentView: View {
    var body: some View {
        Group {
            EmptyView()
            EmptyView()
        }
    }
}

ContentView().body.debug()
// Group<TupleView<(EmptyView, EmptyView)>>
  • Text
Swift
public struct Text: View {
    public typealias Body = Never
    public var body: Never { fatalError() }
    var content: String // In SwiftUI, an enumeration type is used to distinguish between String and LocalizedStringKey, and the imitation process will be simplified uniformly.
    public init(_ content: String) {
        self.content = content
    }
}

struct ContentView: View {
    var body: some View {
        Group {
            EmptyView()
            Text("hello world")
        }
    }
}

// Group<TupleView<(EmptyView, Text)>>

Saving type information in different branches

In the previous post on adding support for multiple branch selection, when processing selections, the AttributedStringBuilder only needs to consider the current branch and does not need to consider the other branch that is not called. The definition of AttributedStringBuilder is as follows:

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

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

However, SwiftUI needs to identify views based on their type and position, so when processing selection branches, regardless of whether the branch is displayed or not, after the view code is compiled, the type information of all branches needs to be clearly defined. SwiftUI uses the _ConditionalContent view type to achieve this.

Swift
public struct _ConditionalContent<TrueContent, FalseContent>: View {
    public var body: Never { fatalError() }
    let storage: Storage
    // Use enumeration to lock type information
    enum Storage {
        case trueContent(TrueContent)
        case falseContent(FalseContent)
    }

    init(storage: Storage) {
        self.storage = storage
    }
}

public static func buildEither<TrueContent, FalseContent>(first content: TrueContent) -> _ConditionalContent<TrueContent, FalseContent> where TrueContent: View, FalseContent: View {
    .init(storage: .trueContent(content))
}

public static func buildEither<TrueContent, FalseContent>(second content: FalseContent) -> _ConditionalContent<TrueContent, FalseContent> where TrueContent: View, FalseContent: View {
    .init(storage: .falseContent(content))
}

Referring to the implementation of the SwiftUI ViewBuilder, the above code can run normally. But I cannot understand the ability of buildEither to infer the types of both TrueContent and FalseContent at the same time. Is this a backdoor opened by the compiler for result builders? I hope friends who understand it can give some hints.

As a result, the types of different branches will be fixed after compilation.

Swift
struct ContentView: View {
    var show: Bool
    var body: some View {
        if show {
            Text("hello")
        } else {
            Text("hello")
            Text("world")
        }
    }
}

ContentView(show:true).body.debug()
// _ConditionalContent<Text, TupleView<(Text, Text)>>

struct ContentView: View {
    var selection: Int
    var body: some View {
        switch selection {
            case 1:
                Text("喜羊羊")
            case 2:
                Text("灰太狼")
            default:
                Text("懒羊羊")
        }
    }
}

ContentView(selection: 2).body.debug()
// _ConditionalContent<_ConditionalContent<Text, Text>, Text>

This implementation is crucial for SwiftUI, as it is one of the important guarantees of implicit identification.

Different buildOptional

In the process of replicating ViewBuilder, the only thing I couldn’t achieve the same as SwiftUI was buildOptional. This is because when SwiftUI was born, result builders use buildIf to handle if statements that do not contain else. Although buildIf is still supported, it is no longer possible to return Optional type data like the official ViewBuilder version.

Swift
// Definition of buildIf in SwiftUI's ViewBuilder
public static func buildIf<Content>(_ content: Content?) -> Content? where Content : View

If we define it as Content?, the compiler will not pass. We can achieve the same purpose (handling if statements that do not contain else) by using _ConditionalContent in buildOptional:

Swift
public static func buildOptional<Content>(_ content: Content?) -> _ConditionalContent<Content, EmptyView> where Content: View {
    guard let content = content else {
        return .init(storage: .falseContent(EmptyView()))
    }
    return .init(storage: .trueContent(content))
}

Although the implementation is slightly different from the original version, the display effect of the translated implementation is exactly the same. We can verify the above code in SwiftUI by adding the following code:

  • Add the following code in the SwiftUI environment:
Swift
public extension ViewBuilder {
    static func buildOptional<Content>(_ content: Content?) -> _ConditionalContent<Content, EmptyView> where Content: View {
        guard let content = content else {
            return buildEither(second: EmptyView())
        }
        return buildEither(first: content) // Because the constructor of _ConditionalContent is not open to public, use buildEither to bridge
    }
}

buildOptional has a higher priority than buildIf. SwiftUI’s ViewBuilder will use the buildOptional we provided to handle if statements without else.

  • Create the following view in a SwiftUI environment:
Swift
struct ContentView: View {
    var show: Bool
    var body: some View {
        Group {
            if show {
                Text("Hello")
            }
            Text("World")
        }
        .debug()
    }
}

// Group<TupleView<(Optional<Text>, Text)>> is the original ViewBuilder parsing type (via buildIf)
// Group<TupleView<(_ConditionalContent<Text, EmptyView>, Text)>> is the replicated ViewBuilder parsing type (via buildOptional)

Although the types of the two translations are slightly different, their display effects are exactly the same.

Support API availability checks

Result builders provide support for API availability checks through buildLimitedAvailablility. It is used in conjunction with buildOptional or buildEither, and the implementation is called when the API availability check is satisfied.

Please consider what is wrong with the following code for buildLimitedAvailability:

Swift
public static func buildLimitedAvailability<Content>(_ content: Content) -> Content where Content: View {
    content
}

@available(macOS 14, iOS 16,*)
struct MyText: View {
    var body: some View {
        Text("abc")
    }
}

struct TestView: View {
    var body: some View {
        if #available(macOS 14, iOS 16, *) {
            MyText()
        }
    }
}

Since MyText should only appear on macOS 14 or iOS 16 and above, although we have provided the buildLimitedAvailability implementation, you will still get the following error message when compiling the code:

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

This is because SwiftUI will fix the types of all views after compilation (regardless of whether the branch is executed), and MyText is not defined in lower versions of the system. To solve this problem, we need to convert MyText to a type that is recognizable in lower version systems. Therefore, the final definition of buildLimitedAvailability is as follows:

Swift
public static func buildLimitedAvailability<Content>(_ content: Content) -> AnyView where Content: View {
    AnyView(content)
}

Creating AnyView

In the world of Swift, it’s inevitable to encounter scenarios where type erasure is needed, and SwiftUI is no exception. For example, the buildLimitedAvailability mentioned earlier uses AnyView to hide unsupported view types in lower version systems, or to convert different types of views to AnyView (since the View protocol uses associated types) to save in an array.

Since SwiftUI identifies views through their type and position in the view hierarchy, AnyView will erase (hide) this important information. Therefore, we should try to avoid using AnyView in SwiftUI as much as possible, unless it’s absolutely necessary.

To allow the ViewBuilder imitation process to continue, we also need to create an AnyView type.

Swift
// There are multiple ways to implement type erasure. The AnyView implementation in this example is quite different from SwiftUI's implementation.
protocol TypeErasing {
    var view: Any { get }
}

public struct AnyView: View {
    var eraser: TypeErasing
    public var body: Never { fatalError() }
    public init<V>(_ content: V) where V: View {
        self.eraser = ViewEraser(content)
    }

    var wrappedView: Any {
        eraser.view
    }

    class ViewEraser<V: View>: TypeErasing {
        let originalView: V
        var view: Any {
            originalView
        }

        init(_ view: V) {
            self.originalView = view
        }
    }
}

Now, the following code can be compiled successfully:

Swift
struct TestView: View {
    var body: some View {
        if #available(macOS 14, iOS 16, *) {
            MyText()
        }
    }
}

// _ConditionalContent<AnyView, EmptyView>

Apple specifically pointed out in the Demystify SwiftUI session at WWDC 2021 that the use of AnyView should be reduced. Besides hiding important type and location information, the conversion process also incurs some performance loss. However, SwiftUI’s implementation of AnyView is very clever, minimizing performance loss to a considerable extent by storing a large amount of original information (dependencies, resolved view values, etc.) within it.

Thus, we have basically finished the replication of SwiftUI’s ViewBuilder and created a builder that can represent the view hierarchy.

Other result builders methods

SwiftUI’s ViewBuilder does not support methods like buildExpression, buildArray, or buildFinalResult. If you have your own needs, you can extend it. For example, you can refer to the example in the previous article and use buildExpression to directly convert a string to Text.

Views without modifiers are incomplete

SwiftUI provides great flexibility for declaring views through view modifiers (ViewModifier). In the last part of this article, we will explore modifiers a bit.

Creating a Generic ViewModifier

SwiftUI provides us with a lot of modifiers, such as in the following code:

Swift
struct TestView: View {
    var body: some View {
        VStack {
            Text("Hello world")
                .background(Color.blue)
        }
        .frame(width: 100, height: 200, alignment: .leading)
    }
}

// ModifiedContent<VStack<ModifiedContent<Text, _BackgroundModifier<Color>>>, _FrameLayout>

background (_BackgroundModifier) and frame (_FrameLayout) are both built-in modifiers.

ViewBuilder is a view builder, and according to the definition of buildBlock, each component must conform to the View protocol. Developers use modifiers to express their ideas in the view, and SwiftUI will only call the implementation of these modifiers during layout and rendering. Considering that the API provided by the View protocol is limited and cannot meet the various needs of modifiers, SwiftUI provides more expression space for modifiers through the ViewModifier protocol (_ViewModifier_Content).

First, let’s create a ViewModifier protocol:

Swift
public protocol ViewModifier {
    associatedtype Body: View
    typealias Content = _ViewModifier_Content<Self>
    @ViewBuilder func body(content: Content) -> Self.Body
}

// _ViewModifier_Content provides additional API, which is not reproduced here.
public struct _ViewModifier_Content<Modifier>: View where Modifier: ViewModifier {
    public typealias Body = Never
    public var body: Never { fatalError() }
}

Create the ModifiedContent view type:

Swift
public struct ModifiedContent<Content, Modifier>: View where Content: View, Modifier: ViewModifier {
    public typealias Body = Never
    public var content: Content
    public var modifier: Modifier
    public init(content: Content, modifier: Modifier) {
        self.content = content
        self.modifier = modifier
    }

    public var body: ModifiedContent<Content, Modifier>.Body {
        fatalError()
    }
}

Creating an overlay method:

Swift
public struct _OverlayModifier<Overlay>: ViewModifier where Overlay: View {
    public var overlay: Overlay
    public init(overlay: Overlay) {
        self.overlay = overlay
    }

    public func body(content: Content) -> Never {
        fatalError()
    }
}

public extension View {
    func modifier<T>(_ modifier: T) -> ModifiedContent<Self, T> {
        .init(content: self, modifier: modifier)
    }

    func overlay<Overlay>(_ overlay: Overlay) -> some View where Overlay: View {
        modifier(_OverlayModifier(overlay: overlay))
    }

    func overlay<Overlay>(@ViewBuilder _ overlay: () -> Overlay) -> some View where Overlay: View {
        modifier(_OverlayModifier(overlay: overlay()))
    }
}

struct TestView: View {
    var body: some View {
        Group {
            Text("Hello")
        }
        .overlay(Text("world"))
    }
}
// ModifiedContent<Group<Text>, _OverlayModifier<Text>>

ModifiedContent identifies itself in the view hierarchy through the generic <Content, Modifier>.

Creating a Modifier for a Specific View Type

In addition to the generic modifiers that conform to the ViewModifier protocol, SwiftUI also has many modifiers that are specific to certain view types, such as Text, TextField, ForEach, and so on, each of which has its own exclusive modifiers. Implementing them is much simpler than generic modifiers, but the approach is slightly different from that described in Using UIKit Views in SwiftUI.

Take Text’s foregroundColor as an example:

Swift
public struct Text: View {
    public typealias Body = Never
    public var body: Never { fatalError() }
    var content: String
    var modifiers: [Modifier] = []  // records the modifiers used
    public init(_ content: String) {
        self.content = content
    }
}

public extension Text {
    // SwiftUI lists the modifiers that apply only to the Text view using an enumeration
    enum Modifier {
        case color(Color?)
        /*
        case font(Font?)
        case italic
        case weight(Font.Weight?)
        case kerning(CGFloat)
        case tracking(CGFloat)
        case baseline(CGFloat)
        case rounded
        case anyTextModifier(AnyTextModifier)
        */
    }
}

Extend Text:

Swift
func foregroundColor(_ color: Color?) -> Text {
    guard !modifiers.contains(where: {
        if case .color = $0 { return true } else { return false }
    }) else { return self }
    var text = self
    text.modifiers.append(.color(color))
    return text
}

There are the following advantages to handling modifiers in this way:

  • The information is only passed during transcription, and the modifier is only processed during layout or rendering.
  • It is easy to be compatible with different frameworks (UIKit, AppKit).
  • The priority logic of modifiers is consistent with SwiftUI’s common modifiers - inner layers have higher priority.

Conclusion

Result builders have been introduced for some time, but they have not been studied in depth. Initially, I only wanted to deepen my understanding of result builders by imitating ViewBuilder, but unexpectedly, this imitation process helped me clarify many confusions related to SwiftUI views, which can be described as a pleasant surprise.

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