This episode is freely available thanks to the support of our subscribers

Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 30% discount for team members. Become a Subscriber

We define the basic view protocols and build a persistent object tree from view values.

00:06 In the SwiftUI Layout Explained series, we reimplemented SwiftUI's layout system to get a better understanding of its inner workings. Today, we're starting a new series in which we want to reimplement SwiftUI's state system.

00:27 If some state changes after a view hierarchy has been rendered in SwiftUI, the system rerenders the parts of the view hierarchy that depend on that particular piece of state. We establish a view's dependency on some state using various property wrappers, such as State, ObservedObject, and Binding. Without fully understanding how these dependencies work, it can be difficult to know when and why state changes trigger view updates. Our goal with this series is to replicate SwiftUI's behavior when it comes to state changes, and to get a feeling for why things work the way they do.

01:34 Something to keep in mind during this series is the difference between initializing a View value and executing its body property. These are two separate actions, and we want to examine and replicate how SwiftUI performs these actions. And in doing so, our goal isn't to write the most efficient code possible, but to make the state system as understandable as possible.

Test Case

02:22 Let's define a simple test case. We create a Model class that has a counter property, and we conform the class to ObservableObject, which we import from the Combine framework. We store an instance of this class in a view, and we let the view observe it. We return a button as the view's body, and it shows the counter's current value and increments the counter when tapped:

import XCTest
@testable import NotSwiftUIState
import Combine

final class Model: ObservableObject {
    @Published var counter: Int = 0
}

struct ContentView: View {
    @ObservedObject var model = Model()
    var body: some View {
        Button("\(model.counter)") {
            model.counter += 1
        }
    }
}

final class NotSwiftUIStateTests: XCTestCase {
    // ...
}

When the button is tapped, it changes the model. After this, the view should automatically be rendered again to show the incremented count.

03:41 We won't be able to make this entire example work in one episode, so we'll skip the ObservedObject property wrapper for now. However, we'll define the View protocol to create the dummy button. And more importantly: we'll create a persistent Node tree that shadows the view tree.

View Lifetime

04:42 In the WWDC talk Demystify SwiftUI, Apple discusses how a view can be represented by different View values over time, but that the view's lifetime is equal to the lifetime of its identity. And a view's identity can be established in different ways:

  • Based on the view's place in the view tree; Apple calls this structural identity

  • Using identifiers, like we do with ForEach or the .id(_:) view modifier; Apple calls this explicit identity

In our implementation, we focus on structural identity to determine a view's lifetime. And we want to persist a view's state in a Node object throughout the view's lifetime.

Creating a Button

05:48 To start our implementation, we need to define some building blocks, starting with View:

protocol View {
    associatedtype Body: View
    var body: Body { get }
}

06:26 We also need a Button view:

struct Button: View {
    var title: String
    var action: () -> ()

    init(_ title: String, action: @escaping () -> ()) {
        self.title = title
        self.action = action
    }
    
    var body: Never {
        fatalError()
    }
}

07:03 The button won't do anything because we won't be rendering anything, so we throw a fatal error in the body property. This means the Button.Body type is Never, so we need to conform the Never type to View:

extension Never: View {
    var body: Never {
        fatalError("We should never reach this")
    }
}

Node Tree

08:15 The next thing we need for our test is a way to convert our View into a Node tree:

final class Model: ObservableObject {
    @Published var counter: Int = 0
}

struct ContentView: View {
    var model = Model()
    var body: some View {
        Button("\(model.counter)") {
            model.counter += 1
        }
    }
}

final class NotSwiftUIStateTests: XCTestCase {
    func testUpdate() {
        let v = ContentView()
        v.buildNodeTree()

    }
}

09:19 For each part of the view tree, we want a node. For our sample view, this means we create a node for the ContentView and one for its body view: the Button. To enable the construction of a tree of nodes, we define a children property — which stores an array of child nodes — on Node:

final class Node {
    var children: [Node] = []
}

11:03 The idea is to construct a node tree once and update it with each "render pass" of our view tree. To update the tree, we write a method on Node, which we can call to let the node update itself if needed. We also add a flag, needsRebuild, that we can flip if the node should update itself:

final class Node {
    var children: [Node] = []
    var needsRebuild = true
    
    func rebuildIfNeeded() {
        if needsRebuild {
            
        }
    }
}

12:42 In our test, we create a root node, and we pass it to a buildNodeTree method on the root view to construct the initial node tree:

final class NotSwiftUIStateTests: XCTestCase {
    func testUpdate() {
        let v = ContentView()
        let node = Node()
        v.buildNodeTree(node)
        
    }
}

13:07 We need to add this method to View. It isn't a protocol requirement, but rather an implementation detail, so we write the method in an extension of the protocol:

extension View {
    func buildNodeTree(_ node: Node) {
        
    }
}

13:45 To execute rebuildIfNeeded, a node needs access to the view it represents in order to call buildNodeTree on it. So we need a way to store the view in the node, but we can't simply store a View value in the node because View is a generic protocol (i.e. it has an associated type):

final class Node {
    var children: [Node] = []
    var needsRebuild = true
    var view: View // 🛑 Protocol `View` can only be used as a generic constraint because it has Self or associated type requirements
    
    func rebuildIfNeeded() {
        if needsRebuild {
            view.buildNodeTree(self)
        }
    }
}

15:02 We can get around this generic problem by distinguishing between user-defined Views and BuiltinViews. The BuiltinView protocol can have the buildNodeTree method as its single requirement, thus avoiding the need for an associated type. This makes it possible to use BuiltinView as a wrapper around View and to store view values in Node.

15:40 Many views will conform to both View and BuiltinView, so we prefix BuiltinView's method name with an underscore to make it explicit which implementation we're calling:

protocol BuiltinView {
    func _buildNodeTree(_ node: Node)
}

final class Node {
    var children: [Node] = []
    var needsRebuild = true
    var view: BuiltinView
    
    func rebuildIfNeeded() {
        if needsRebuild {
            // ...
        }
    }
}

16:06 Now we can conform Button to BuiltinView:

struct Button: View, BuiltinView {
    var title: String
    var action: () -> ()

    init(_ title: String, action: @escaping () -> ()) {
        self.title = title
        self.action = action
    }
    
    func _buildNodeTree(_ node: Node) {
        // todo create a UIButton
    }

    var body: Never {
        fatalError()
    }
}

16:28 We never execute the body property of built-in views, so we add a default implementation. After this is done, we can remove the body implementation from Button:

extension BuiltinView {
    var body: Never {
        fatalError("This should never happen")
    }
}

struct Button: View, BuiltinView {
    var title: String
    var action: () -> ()

    init(_ title: String, action: @escaping () -> ()) {
        self.title = title
        self.action = action
    }
    
    func _buildNodeTree(_ node: Node) {
        // todo create a UIButton
    }
}

16:49 In Node, we turn the view property into an implicitly unwrapped optional, and we call the _buildNodeTree method instead of buildNodeTree:

final class Node {
    var children: [Node] = []
    var needsRebuild = true
    var view: BuiltinView!
    
    func rebuildIfNeeded() {
        if needsRebuild {
            view._buildNodeTree(self)
        }
    }
}

17:19 Now the code compiles, but we still need to implement buildNodeTree on View. Let's imagine calling this method on ContentView. Then we know we need to create a child node for its body view, if we don't have one yet. But if we already have a child node, we reuse it — this is how we preserve the view's state.

Once we have the child node, we execute the view's body, and we call the body view's buildNodeTree with the child node. And at the end of the method, we set needsRebuild to false:

extension View {
    func buildNodeTree(_ node: Node) {
        let b = body
        if node.children.isEmpty {
            node.children = [Node()]
        }
        b.buildNodeTree(node.children[0])
        node.needsRebuild = false
    }
}

19:20 At this point, the compiler correctly points out that all paths through the above function will result in the function being called again. To break out of this infinite loop, we need to check if the view conforms to BuiltinView, and if so, call its _buildNodeTree method instead of recursively building a child node:

extension View {
    func buildNodeTree(_ node: Node) {
        if let b = self as? BuiltinView {
            node.view = b
            b._buildNodeTree(node)
            return
        }
        let b = body
        if node.children.isEmpty {
            node.children = [Node()]
        }
        b.buildNodeTree(node.children[0])
        node.needsRebuild = false
    }
}

20:44 If the view is not a built-in view, we can't store it in the node as is. This is where an AnyBuiltinView can help us out. This wrapper erases the view's generic type by only storing the view's buildNodeTree method:

struct AnyBuiltinView: BuiltinView {
    private var buildNodeTree: (Node) -> ()
    
    init<V: View>(_ view: V) {
        self.buildNodeTree = view.buildNodeTree(_:)
    }
    
    func _buildNodeTree(_ node: Node) {
        buildNodeTree(node)
    }
}
extension View {
    func buildNodeTree(_ node: Node) {
        if let b = self as? BuiltinView {
            node.view = b
            b._buildNodeTree(node)
            return
        }
        node.view = AnyBuiltinView(self)
        
        // check if we actually need to execute the body
        
        let b = body
        if node.children.isEmpty {
            node.children = [Node()]
        }
        b.buildNodeTree(node.children[0])
        node.needsRebuild = false
    }
}

Finishing the Test

23:14 We should now have everything we need to get the test working. With the built node for the ContentView, we can try to find a button with the text "0" on it by reaching into the node's first child and checking its view property. We force-cast that view to Button — because it would be a programmer error if it's any other type — and we assert the button's title is what we expect it to be:

struct ContentView: View {
    var model = Model()
    var body: some View {
        Button("\(model.counter)") {
            model.counter += 1
        }
    }
}

final class NotSwiftUIStateTests: XCTestCase {
    func testUpdate() {
        let v = ContentView()
        let node = Node()
        v.buildNodeTree(node)
        let button = node.children[0].view as! Button
        XCTAssertEqual(button.title, "0")
    }
}

24:55 Next, we want to assert that the view updates when we execute the button's action and rebuild the node tree. We change the button variable into a computed property so that we can reuse it anytime we need to retrieve it from the node:

final class NotSwiftUIStateTests: XCTestCase {
    func testUpdate() {
        let v = ContentView()
        let node = Node()
        v.buildNodeTree(node)
        var button: Button {
            node.children[0].view as! Button
        }
        XCTAssertEqual(button.title, "0")
        button.action()
        node.rebuildIfNeeded()
        XCTAssertEqual(button.title, "1")
    }
}

26:01 But that doesn't work yet. Although rebuildIfNeeded already goes through the entire node tree by recursively calling buildNodeTree, none of the nodes pass the needsRebuild check because we never flip that flag to true. We need to subscribe to the model's objectWillChange publisher to figure out which nodes need to be rebuilt. For now, we can see if the rebuild works by manually setting needsRebuild to true in our test:

final class NotSwiftUIStateTests: XCTestCase {
    func testUpdate() {
        let v = ContentView()
        let node = Node()
        v.buildNodeTree(node)
        var button: Button {
            node.children[0].view as! Button
        }
        XCTAssertEqual(button.title, "0")
        button.action()
        node.needsRebuild = true // TODO this should happen automatically
        node.rebuildIfNeeded()
        XCTAssertEqual(button.title, "1")
    }
}

27:10 Now the test succeeds. In the next episode, we'll implement ObservedObject, which can observe the model and set needsRebuild when the objectWillChange publisher fires.

27:40 By flagging views with needsRebuild instead of immediately updating the view when a change happens, we ensure that we don't unnecessarily update a view more than once. Rather than rerendering views for every state change, we just mark the dependent parts of the view tree as dirty, and we go over the dirty views all at once at the next tick of the run loop.

Resources

  • Sample Code

    Written in Swift 5.4

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

See All Collections

Episode Details

Recent Episodes

See All

Unlock Full Access

Subscribe to Swift Talk

  • Watch All Episodes

    A new episode every week

  • icon-benefit-download Created with Sketch.

    Download Episodes

    Take Swift Talk with you when you're offline

  • Support Us

    With your help we can keep producing new episodes