8 Episodes · 2h49min
- Views and Nodes 28:43
- Observed Objects 14:07
- Tuple Views and View Builders 22:50
- Comparing Views 18:08
- Bindings 22:27
- State Properties 27:44
- State Dependencies 14:53
- State and Bindings 20:19
Swift Talk # 261
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.
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.
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.
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")
}
}
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 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 View
s and BuiltinView
s. 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 isn't 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
}
}
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 any time 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.
Written in Swift 5.4
Become a subscriber to download episode videos.
8 Episodes · 2h49min
Episode 430 · Nov 22
Episode 429 · Nov 15
Episode 428 · Nov 08
Episode 427 · Nov 01
Episode 426 · Oct 25
Episode 425 · Oct 18
Episode 424 · Oct 11
Episode 423 · Oct 04
Unlock Full Access
A new episode every week
Take Swift Talk with you when you're offline
With your help we can keep producing new episodes