Learn SwiftUI’s View struct: value semantics, diffing and dependency tracking
Learning SwiftUI can be tricky because it has a very different design to previous UI frameworks. Many make the mistake of trying to apply old design patterns which are not really compatible because of SwiftUI’s use of value semantics and its diffing algorithm – most developers from UIKit, Java or .NET backgrounds are used to using reference semantics, i.e. classes and objects. If one were to apply reference semantics, i.e. use objects for their view data instead of learning SwiftUI’s View struct and diffing algorithm they will re-introduce the kind of consistency bug that SwiftUI’s use of value semantics was designed to eliminate. For this reason I’ve created this simple guide to help people learn SwiftUI in a different way from other tutorials I’ve seen. I wrote this from scratch myself so please let me know of any mistakes and I’ll fix them.
struct ContentView: View {
let content: Int
var body: some View {
Text(content, format: .number)
}
}
Above is a “simple” SwiftUI View struct. It looks simple, however there is actually a lot of hidden complexity which I will explain later. First of all it is important to understand that this View is a lightweight struct stored on the stack, like a simple value, e.g. an Int or a String. Initing this View struct over and over again as negligible performance overhead. This differs from a UIKit UIView object that would be init on the heap once and then updated because there is more overhead in creating objects, especially ones that are layer-backed and do rendering. In SwiftUI these View structs are actually init over and over again, in any change to the data, so it is important that all of their properties are also value types, because if objects are used then this is a serious memory leak.
So lets use this View struct, imagine somewhere else in the code there was ContentView(content: 5)
and then some event causes it be changed to ContentView(content: 10)
. The first time we init it with content = 5 and then the second time we init it with content = 10. Because with value semantics any change to a property is seen as a change to the struct, SwiftUI can detect that this View struct has changed and this is the point where the body
property is called. This is the first and simplest part of SwiftUI’s dependency tracking system – body is called if any of the let properties have changed. It is assumed that the let properties are being accessed inside body, otherwise why would they have been defined there at all? It’s also important to know that body
is only called if the underlying UIView for this View is on screen. E.g. if this was List
content, body
is only called for the Views that represent the cells in the UICollectionView
that are on screen (Note pre-iOS 16 List
used UITableView
). It’s important to also know that since cells are reused, the onAppear
modifier is only called once since that relates to UIView
s and not View
structs.
SwiftUI is designed to be a cross-platform framework, this means we define the UI using these View structs, e.g. Text, List, Button, ForEach (this is a really badly named one), and they decide what UIView (NSView on Mac) object to use, and they might use different UIViews depending on the platform or the context of the device. This makes naming of the View structs really difficult for Apple because they need to make it generic enough for each context but also specific enough so we as developers know what one to use. In most UI frameworks we are familiar with something named label but in SwiftUI we have to learn to use Text
. So back to our simple ContentView
and the body
property inits a Text
. Under the hood, when this Text
is to be displayed on screen, SwiftUI inits a UILabel
object automatically on our behalf, and as the ContentView
is re-init again with different content, it finds the UILabel
object again and updates it (using a feature called View Identity that I’ll explain later on). When Text
is no longer required to be displayed, it removes the UILabel
object from the screen and de-inits the object. Every time Text is re-init, it is diffed, and the resulting changes are used to updated the UILabel
object. Diffing these View struct value types is very fast and efficient. If you are familiar with NSDiffableDataSource
it is a bit like that, but for the entire view hierarchy, not just for a table. SwiftUI is closed-source but we believe a graph-like data structure is used for the diffing.
Once this is understood we might think this is quite simple and we could achieve the same kind of diffing ourselves in UIKit. However inside Text
there is actually a lot of other magic going on. The UILabel
is updated if the system font size changes or if it is overridden with a font view modifier. If the number is 1,000 and the user changes their region settings are changed to Germany then the UILabel
is updated with 1.000. This is the real power of SwiftUI, all of these edge cases often over looked by developers are handled automatically by SwiftUI. In UIKit we would need to subscribe to many NSNotifications
to update the UILabel
in all the same ways ourself. And once the UILabel depends on some asynchronous data as-well, it is pretty much game over for UIKit.
So we have covered the basics of value semantics, diffing and body, lets move on to editing data.
struct ContentView: View {
@Binding var content: Int
var body: some View {
TextField("Number", value: $content, format: .number)
}
}
To understand how to edit data we need to first understand closures and property wrappers. Closures are a language feature of Swift where a block of code can be supplied that can be executed later. A closure is believed to begin as a reference type, which we know is bad for SwiftUI, however it seems that when closures are used with value types some compiler optimisation allows them to be stored on the stack like a value type, keeping SwiftUI’s diffing algorithm fast and memory efficient. Property wrappers are a way to reuse logic across multiple View structs which would traditionally be implemented by subclassing. @Binding
is a property wrapper for a Binding
type that is essentially a pair of get and set closures, the @ syntax allows the value represented by the Binding
to be used directly, e.g. content = 10
, simplifying the code.
Instead of passing in a readonly let property, we can pass in get and set closures that allow the View
to read and write the data that is defined somewhere else. The init looks like Binding(get: { }, set: { })
and there is a convenience syntax $ that various types implement (via a projectedValue
property) to return a Binding
. So now if somewhere else in the code our View struct is init like ContentView(content: Binding<Int>(get: {}, set: {})
or ContentView(content: $content)
. Since the Binding
init or the $ convenience both create a new unique Binding
value every time because closures are not equatable like ints are, body
is always called, the same as in our let content: Int
example when the content is different. So again the basic form of dependency tracking occurs here, a change to the content Binding
is considered a change to the ContentView
itself, thus body
is called.
Now that we know how to read and write data, we now need to know how to define data. In SwiftUI the View structs are immutable, that means they cannot be changed, and allows them to be copied efficiently (Swift uses copy-on-write) when stored in the graph structure used by the diffing algorithm. This means we should be sure to use let
and not define properties as var content: Int
(I’m not sure why so much of Apple sample code has this mistake). So to store data in a property that is mutable we need another property wrapper @State
. This one allocates some storage in a reference type with a lifetime tied to the View
and uses the View
Identity to keep track of what View structs are using what states. So now if we declare a property @State var content: Int = 0
it essentially simulates that the View struct is now mutable (i.e. can be changed) and we can successfully do content = 10
which failed earlier.
SwiftUI’s dependency tracking (that is when it decides to call body
) works in a more advanced way for @Stat
e compared to let
and @Binding var
. The reason is because the state value is not held in the struct but it is externally in the state storage object thus it cannot use equality to detect changes. According to a WWDC video, SwiftUI tracks what state properties are read from body the first time, then when the state is changed, it calls body
again on all of the View structs it tracked. Thus in the case of @State
, body
is only called for properties that are used in body
, unlike let
and @Binding var
where body
is called regardless if it is used or not. As I said earlier this is usually not an issue because why would it be declared if it wasn’t used? Well there is an edge case that requires some advanced knowledge of closures. Some SwiftUI Views use closures to supply additional Views, e.g. .navigationDestination
and .sheet
, if these use state in their closures but it is not read anywhere else in body the dependency tracking fails and these closures are not recomputed when the state changes. This can be worked around by adding the state to the closures capture list. This problem is a moving target because as SwiftUI improves it gets better at tracking dependencies through closures.
Now that we have an understanding of SwiftUI’s dependency tracking, hopefully we are realising that we should make our View structs as small as possible, only declaring the lets/vars we actually need, so that body
is only called when necessary. Apple call this kind of design having a tight invalidation. A lot of Apple’s sample code can have quite large body
vars, so it isn’t quite clear yet what performance gains can be had from tight invalidation vs weak, but it does seem like good practice. Furthermore in a WWDC video we were told when designing Views to first ask ourselves “what data does this View
need to do its job?”.
Declaring a state is also called declaring a source of truth. We position the state in the View
hierarchy in a common parent of all the View
structs it is required to be passed into. We pass in a let
property for read access or @Binding var
for write access. Now lets apply everything we learned to some sample code:
struct ContentView: View {
@State var content: Int = 0 // source of truth
// body is called if this View is on screen and the content state has changed.
var body: some View {
ReadContentView(content: content) // pass in read access
WriteContentView(content: $content) // pass in write access, via a pair of get/set closures represented in a Binding struct.
}
}
struct ReadContentView: View {
let content: Int
// After init, body is always called when on screen and content is different.
var body: some View {
Text(content, format: .number)
}
}
struct WriteContentView: View {
@Binding var content: Int
// After init, body is always called when on screen (content is a binding which is a pair of closures which are always different)
var body: some View {
TextField("Number", value: $content, format: .number)
}
}
In the code sample above we have a source of truth and pass read access down to one View
and write access to another. We have created a View
hierarchy! Another great advantage to the View
hierarchy is it allows us to transform from rich model types to simple value types. A great way to do this is using a computed property. In the code sample below we create a computed property to transform our content and multiply it by 10 for display in the ReadContentView
:
struct ContentView: View {
@State var content: Int = 0
// data transformer
var contentMultipliedBy10: Int {
content * 10
}
var body: some View {
ReadContentView(content: contentMultipliedBy10) // pass in transformed data
WriteContentView(content: $content)
}
}
The reason this is an efficient way to do this is since body
is only called when the content is changed, the computed transformed property will only be called on every change, and also in the sub-View, ReadContentView
‘s body will only be called if the transformed value is different. Thus given the situation where a change in the content value results in the same transformed value, body won’t be called on the child View, which is a pretty good optimisation. The computed property is a good place to do filtering or sorting of arrays.
@State
is not limited to simple values, it can also be used with a custom struct which a great way to group related vars and is also a way to extract logic into something that can be tested independently. It also presents an interesting possibility for implementing didSet
to update another var, something that cannot be done with @State var
in the View struct. The following example changes our content from an Int to a custom struct:
struct Content {
var a: Int = 0
var b: Int = 0 {
didSet {
// could update a related var
}
}
mutating func inc() {
a += 1
b += 2
}
}
struct ContentView: View {
@State var content: Content = Content()
var body: some View {
Button("Inc \(content.a) \(content.b)") {
content.inc()
}
}
}
I hope this post has been helped your understanding of SwiftUI and I hope my unique angle on it of covering value-semantics, the View struct, closures, binding then state has been useful.