- Published on
SwiftUI for UIKit developers - Part 2
- Authors
- Name
- Rad'Val
- @rad_val_
Last time we discussed how SwiftUI is an immutable, declarative DSL that treats views as a function of state. This nicely sums up its main features but doesn't tell us much about the inner workings. Today we'll start exploring SwiftUI from the inside out and hopefully, this will give us a good intuition when it comes to learning the API, debugging, and in general, best practices.
Disclaimer:
Unless you're an Apple engineer, there's sadly no way to tell how SwiftUI is built. All the knowledge in these articles comes from inspecting the SwiftUI interface and reverse engineering a minimal SwiftUI clone. There's no guarantee that the actual implementation looks anything like what's presented here.
However, I still find the process of thinking about the challenges Apple faced when designing SwiftUI highly educative. And even if the implementations might differ, following similar steps helps a lot.
We'll start with the main SwiftUI entity, the View
:
Then talk about:
View
Anatomy of a SwiftUI The View
is the central protocol in SwiftUI. If we jump to its definition in Xcode, we will see this (comments removed for brevity):
public protocol View {
associatedtype Body: View
@ViewBuilder var body: Self.Body { get }
}
A protocol that requires a body
, of the same type, View
. A typical, run-of-the-mill view, implementing the protocol, would look like this:
struct ArticleView: View {
private let _title: String
private let _content: String
init(_ title: String, content: String) {
_title = title
_content = content
}
var body: some View {
VStack {
Text(_title)
Text(_content)
}
}
}
We'll call _title
and _content
dependencies, although, in plain Swift jargon, they're just two members or properties. This new terminology will make a lot more sense once we see how SwiftUI unifies dependencies regardless of their origin. Other than that, there's not much more to it: ArticleView
uses these two dependencies and describes the layout of the two Text
components, stacking them one under the other, using a vertical stack layout (VStack
).
Notice:
I used "describe" here, not render, nor create, nor build because all these views are descriptions of the rendered entities. Think about it as a shadow view tree, mirroring the actual entities while simultaneously not being them. For the rest of the series, I will use "build" or "create" and "views" instead of descriptions, since it makes sense from the SwiftUI's developer perspective, even if it obfuscates the underlying mechanics.
So, each view is expected to describe its children inside the body
computed property. But what if a view has no subviews? Well, body
can be Never
in that case. This means we could iterate the entire view tree like this:
func print<V: View>(view: V) {
print(V.self)
if V.Body.self != Never.self {
print(view: view.body)
}
}
And it works great with our custom views:
struct ProfileImage: View {
var body: some View {
Image("placeholder")
}
}
struct ProfileView: View {
var body: some View {
ProfileImage()
}
}
print(view: ProfileView())
// prints:
// ProfileView
// ProfileImage
// Image
But fails with framework-provided views, like VStack
:
print(view: VStack {
ProfileView()
})
// prints only:
// VStack<ProfileView>
It seems VStack
's body type is Never
. But how can that be? It has a subview! Turns out that the reason behind it is quite interesting and closely related to how SwiftUI works internally. We'll explore it in the next article.
View
s are always immutable and their body
is usually a computed property. If any dependency that would affect at least one child view changes, the entire body
is optimally recomputed (emphasis important).
In SwiftUI all state is stored externally, relative to the view, preserving the view's immutability. Here's what a stateful view looks like:
struct UserView: View {
@State var showDetails: Bool = false
var body: some View {
if showDetails {
UserDetailsView()
}
Button(action: { showDetails.toggle() }) {
Text("Toggle details")
}
}
}
showDetails
is a property of the view, you can access it like any other property, but underneath, the @State
property wrapper externalizes its storage. In plainer words, the bool
value is not stored with the struct
, but in another place, unknown to us and fully managed by SwiftUI. This is the reason for which views can be destroyed and recreated and still, their state persists.
Multiple stateful property wrappers work this way: @Environment
, @EnvironmentObject
, @StateObject
, etc. We'll go through each in a later article, right now just keep in mind that views are immutable and their state is externally stored.
Identity, lifetime, and dependencies
Identity
By now you're probably wondering: if the state is externally stored and views are recreated on state updates, how does SwiftUI map back the right state to the right recreated view? Also, since views are only descriptions, how does SwiftUI map them to the underlying rendering entities? View identity is the answer to all that.
Even if you have little interest in how SwiftUI works internally, understanding view identity is crucial in debugging transitions, animations, building complex layouts, and improving performance.
In SwiftUI there are two kinds of identity: structural identity and explicit identity.
Structural identity relies on the view's fully qualified type, which reflects the view's place in the views hierarchy. For example:
VStack {
Text("Hello")
Text("World")
}
Here, the first text view identifier would conceptually be VStack -> Text
. The type of the parent (VStack
), then the type of the view, Text
. However, the second view would have the same identifier, and that can't be good. To disambiguate between the two, we also need its index within the parent. Then we would have VStack -> Text -> 0
and VStack -> Text -> 1
. Now identifiers are unique.
A possible implementation for computing a view's structural identity would be:
func identity<ParentID, Salt>(from parentId: ParentID,
salt: Salt) -> some Hashable
where ParentID: Hashable, Salt: Hashable {
var hasher = Hasher()
hasher.combine(parentId)
hasher.combine(ObjectIdentifier(Self.self))
hasher.combine(salt)
return hasher.finalize()
}
This combines the unique parent identifier with the view's metatype value and a salt value. Normally, the salt would be the position of the view among its siblings, as discussed above.
However, there's a problem with this approach. When the children are dynamically created at runtime, using the child's position in the list doesn't return a stable identity anymore. This is because indices can easily change when a child is removed or the list is reordered, in such a way that the same view can have various positions during the lifetime of the parent.
To fix this, SwiftUI introduces explicit identity. Using the .id()
modifier, one can explicitly disambiguate between the children of a view. Each provided identifier must be stable, enabling SwiftUI to use it as the salt in the algorithm above.
Lifetime
Lifetime is closely related to identity. Remember we said that our views are only descriptions of the actual entities computing the layout and rendering things on screen? The lifetime refers to those entities' lifetime. A description, or view value, as Apple calls it, will be destroyed and recreated often as the state updates. This can happen for various reasons, sometimes unrelated to the value itself, like an unrelated dependency update that invalidates an entire body
. Meanwhile, the underlying entity will only be destroyed if it gets off the screen.
The mapping between the view values and the underlying entities is done using the view's identity. The identity is stable and never changes, regardless of how many times we recreate our views.
Dependencies
Dependencies come in many forms in SwiftUI. We briefly talked about the simplest of them all: init
-injected properties of a view. @State
-marked properties are as well dependencies. So are @Environment
and @EnvironmentObject
properties. Anything external, injected into a view is a dependency. Collectively they are things that the view uses to describe its contents, and such, anytime a dependency changes, the affected views are invalidated.
Dependencies are a fascinating topic. It's the most complex and beautiful part of SwiftUI. I've seen a couple of SwiftUI reimplementations and none got this right. I'm still struggling to understand how they fully work too, but I can tell Apple put a lot of effort into making SwiftUI transactions as optimized as possible.
By the way, a transaction is the process that starts when a dependency changes, invalidating views, and ends when the said views reflect the new state once again. It has roughly 5 steps:
- identify which of the views are affected
- inject the updated dependency into them
- recompute their
body
- update the dependency graph, if required
- reconcile any new or updated views with their underlying entities
The last step is the most complex. But this topic deserves an article on its own. So join me next time when we'll have a closer look at the dependency graph SwiftUI builds from our view declarations and how this graph helps reconciliation and diffing.