Pro to SwiftUI: Dive into the View Identity
This article talks about the view identity. We are using view identity implicitly or explicitly through out our app. But without some cautions we may fall into some pitfalls.
Why does SwiftUI need View identity?
SwiftUI is a declarative UI framework. You describe the UI in a property named body in a struct conforming View protocol and SwiftUI talks to the UIKit, AppKit or the underlying drawing framework to draw and maintain the UI on screen.
Each SwiftUI View is a struct, which is a value type. Each time the body property is called, it returns a new struct representing the UI for a given state. This mechanism implies that the View structures are not retained in the memory and once they have been consumed, the memory should be free.
Let’s look at this simple example:
Each time we press the button, the compute property body
of ContentView
is called and a VStack
containing the sub views with updated clickedCount
state is return. And once this struct is consumed, the memory of this VStack
and its subviews are free and the UI is updated.
What had happened here?
The View
we provide is just a surface in the whole process. The underlying part of the View is the AttributeGraph
, and then its counterpart is either UIView
or NSView
.
So what’s AttributeGraph
? This concept is not documented in the develop website, but Apple has been discussed this in its WWDC videos. Let’s take a look at a snippet.
Behind the scenes, SwiftUI maintains a long-lived dependency graph that manages the lifetime of views and their data. Each node in this graph, called an attribute, maps to a fine-grained piece of the UI. When the selected state changed to true, the value of each of these downstream attributes became stale. They're refreshed by unwrapping the new view value one layer at a time.
Once the corresponding graph attributes have been updated, the view's body value is discarded. Finally, the graph emits drawing commands on your behalf to update the rendering.
To make it more clear you can think in this way:
SwiftUI asks you how to draw a UI
You response to SwiftUI handing over a view
SwiftUI receives to your view, and then updates its underlying
AttributeGraph
and throws the view into the trash bin
SwiftUI draws UI based on the updated
AttributeGraph
In conclusion, the View
is the short-term form in the part of drawing your UI, and of course it is because it’s a value type in Swift.
However, a View can have its own State
, which should be a long-lived one during the lifecycle of an app. Since the lifecycle of the View struct is short, how is the state associated with this view?
In fact, when you create a new value of a View with its own states, SwiftUI injects the state for you.
To better understand this, let’s add some logs to the example below:
When the app launch, you can see these logs:
ContentView init
ContentView: @self, @identity, _clickedCount changed.
ButtonWrapperView init
ButtonState init
ButtonWrapperView: @self, @identity, _internalState changed.
Note that you can use let _ = Self._printChanges()
to let the system print the log when the body of the view is called.
The ContentView
structure is created, and then the body property is called. Inside the body property of the ContentView
, a ButtonWrapperView
is created and so its body property. The ButtonWrapperView
has a state object and we can also see that the initializer of ButtonState
is called.
When we click the button to update the clickedCount
, we can see the logs below:
ContentView init
ContentView: @self, @identity, _clickedCount changed.
ButtonWrapperView init
ButtonState init
ButtonWrapperView: @self, @identity, _internalState changed.
// We click the button
--------
ContentView: _clickedCount changed.
ButtonWrapperView init
ButtonWrapperView: @self changed.
The initializer of ButtonWrapperView
is called because the body of ContentView
is called and so its body property. But you should notice that the initializer ButtonState
is not called although it looks like it should be called.
You should also notice that the ButtonState
is managed by a property wrapper named StateObject
, which is responsible for getting the “right” ButtonState
across the view updates. But how can SwiftUI determinate which is the “right” ButtonState
?
Well, it’s the one that bound to the identity of the View, NOT the lifecycle of its memory.
In conclusion, the identity of a view helps SwiftUI manage the lifecycles of its dependencies and the underlying platform views, such as UIView and NSView.
The whole concept of AttributeGraph
in SwiftUI is internal and complex, but we don't necessarily need to deal with it directly. Providing correct View identities improves the app's performance and correctness of UI.
But how to get to know the identity of a view? Let’s talk about the types of view identity.
Types of View identity
There are two types of View identity:
Implicit or structural identity
Explicit identity
Structural identity
You can think of the structural identity of a view as its address in the view hierarchy. During the view updates, if the address of a view remains unchanged, then the view's structural identity remains identical.
Consider this example:
Note that there are two different ButtonWrapperView
returned with the if-self statements. If we click the button twice, the logs should be blow:
ButtonState init
ButtonState init
ButtonState deinit
ButtonState init
ButtonState deinit
The ButtonState
is deallocated now. That’s because the structural view identity of ButtonWrapperView
is changed.
When you use if
or switch
statement in a View, you are creating different structural identities for the subviews.
The two structural identities of the ButtonWrapperView
is depicted as blow:
There are two structural identities of the ButtonWrapperView
. As you can see, there is a yellow and a purple path, and each represents the structural path of the a ButtonWrapperView
. Although the ButtonWrapperView
is construct with the same way, which is:
Each time the clickedCount
is changed, the new ButtonWrapperView
’s structural identity is changed. To be more specified, the structural identity is changed from:
ContentView → VStack(1) → If(0)
to:
ContentView → VStack(1) → If(1)
With the changed of structural identity, the underlying resources of the ButtonWrapperView
, like the attributes in AttributeGraph
, the NSButton
if this app runs on macOS and the ButtonState
associated with this ButtonWrapperView
, are deallocated.
Thus, from the logs above, as we click the button to change the structural identity of ButtonWrapperView
, the ButtonState
object keeps allocating and deallocating, which make senses.
Note that the input parameters won’t affect the structural identity of a view. Consider this example below:
Each time we click the button, the clickedCount
will be changed and passed to construct a new ButtonWrapperView
, which still remains the identical structural identity.
Be mindful as using bridged platform views
If your view uses a underlying platform specified views, be mindful of the performance overhead when changing view identities as it may create or recreate your platform views frequently.
For example, consider a view that shows a ProgressView
when loading and shows the custom UIViewController
when loaded.
The MyUIViewControllerView
uses a UIViewController
as blow:
If you run this code, you can see that the initializer of MyHeavyUIViewController
is invoked twice: one when the self.loading
is false and one when the self.loading
is true. If the loading process can be triggered by some external events like syncing from cloud, this can cause performance issue if your view controller does heavy stuff inside and will not preserve any internal states like the scrolling position of a UICollectionView
.
This issue occurs when showing a progress view or a view via if
or switch
statement. You can improve this by stacking the views using ZStack
or use overlay
or background
modifier.
Explicit identity
While there is a implicit(structural) identity, there should be a explicit identity.
That’s true, you can use id(_:)
to specify the identity of a view. This explicit way to set the id seems straightforward but it’s not quite easy as you think.
The SwiftUI follows the structural identity first before taking the explicit identity into account.
Consider the example above:
If we set the id of MyUIViewControllerView
to be “fixed_id”
, when the loading
state changes from false to true, the final identity of MyUIViewControllerView
will still change because its structural identity changes.
The id(_:)
view modifier can be used when you want to force to change the identity of a view, given a stable structural identity.
For example, consider we are using a third-party LazyImage view that loads an URL into an image data:
If the state contains the image data, we can display a Image
in it. However, this loading process can still be failed. This framework won’t allow us to retry loading the image. How can we force to retry loading the image when we tap? In this case, you can maintain an internal state of id and change it accordingly.
In this example, we maintain a UUID string as an internal state of this view. When the uuid state changes, the explicit identity of this LazyImage
will change and thus the onAppear(perform:)
or task(priority:_:)
will be invoked, where the third-party view LazyImage
will load the URL internally.
If you ever use a ScrollView
combining LazyVStack
and a ForEach
, you may also notice that either the collection’s elements must conform to Identifiable or you need to provide an id parameter to the ForEach
initializer. This is also an explicit but special identity: you are telling SwiftUI that the view should be bound to that id, even though the structural position of this view can be changed as user scrolls the ScrollView
. But the views insider the LazyVStack
has an explicit identity, their final identities can still change if the view identity of LazyVStack
changes.
Handle View Identity changed
SwiftUI has a paired onAppear(perform:)
and onDisappear(perform:)
and task(priority:_:)
method to handle changed of a view’s identity. The description of this methods look unclear as the onAppear says:
Adds an action to perform before this view appears. The exact moment that SwiftUI calls this method depends on the specific view type that you apply it to, but the action closure completes before the first rendered frame appears.
It’s not equivalent to some methods in UIKit like viewDidAppear(_:)
but those methods are perfectly good ways to perform actions as the identity of this view changes. For example:
You can start a async task loading data using
task(priority:_:)
. If the view identity is changed during the loading progress, the task will automatically marked cancelled and you can useTask.checkCancellation
to cancel the actual work.
While the paired
onAppear(perform:)
andonDisappear(perform:)
only match a view’s identity, you can’t get the properties of the view like the size. Hey, it’s a declarative UIFramework, you don’t do stuffs like this. Instead, you useGeometryReader
to get the size of its subview.
Afterwords
View identity is an important but implicit concept when developing using SwiftUI. You may break the view identity without noticing it and cause unexpected UI behavior or performance issues. I hope this article can help you understand this concept and solve your issues.
Thanks for reading :)