In the world of UIKit (AppKit), developers can inject their will into various nodes of the view controller lifecycle, akin to deities, through a multitude of hooks provided by the framework, such as viewDidLoad
, viewWillLayoutSubviews
, etc. In SwiftUI, the system reclaims these rights, and developers largely lose control over the view lifecycle. Many SwiftUI developers have encountered situations where the behavior of the view lifecycle is beyond expectations (e.g., views being constructed multiple times, lack of control over onAppear
, etc.).
This article introduces the author’s understanding and research on SwiftUI views and the SwiftUI view lifecycle for discussion.
Before delving into more detailed explanations, please clarify two points:
- SwiftUI does not have corresponding views and view lifecycles like UIKit (AppKit).
- Assumptions about the timing and frequency of the creation, calling of the
body
, layout, and rendering of SwiftUI views should be avoided.
SwiftUI Views
In SwiftUI, a view defines a piece of the user interface and is organized in the form of a view tree. SwiftUI creates appropriate renderings by parsing the view tree.
Internally, SwiftUI will create at least two types of trees—a type tree and a view value tree.
Type Tree
Developers define the user interface they want to present by creating structs that conform to the View
protocol. The body
property of the struct is a large type with many generic parameters, which SwiftUI organizes into a type tree. It contains all types conforming to the View
protocol that might appear on the screen during the app lifecycle (even if they may never be rendered).
For example:
struct ContentView: View {
var body: some View {
Group {
if true {
Text("hello world")
} else {
Text("Won't be rendered")
}
}
}
}
The above code will be constructed into the following type tree:
Group<_ConditionalContent<Text, Text>>
Even if Text("Won't be rendered")
is never displayed, it is still included in the type tree.
The type tree is fixed after compilation and does not change throughout the app’s lifecycle.
View Value Tree
In SwiftUI, views are a function of state.
Developers declare interfaces through structs conforming to the View
protocol. SwiftUI obtains the corresponding view values by calling the body
of the struct instances. The body
computes the view values based on the user’s interface description and the corresponding dependencies (Source of truth).
When the app runs and is rendered for the first time, SwiftUI creates type instances according to the type tree. The instances’ body
, based on the initial state, calculates the view values and organizes them into a view value tree. Which instances to create is determined by the state at the time, and each state change may result in a different view value tree (it could be a change in the view value of a node, or a significant change in the structure of the view value tree).
When the State changes, SwiftUI generates a new view value tree (nodes whose Source of truth has not changed are not recalculated and use old values) and compares it with the old view value tree. SwiftUI then re-layouts and renders the parts that have changed, replacing the old view value tree with the new one.
The view value tree typically only saves content required for the current layout and rendering (in some cases, a few view values not involved in layout and rendering are cached). It continuously changes throughout the app’s lifecycle as the State changes.
What is a View
Developers are more accustomed to considering structs conforming to the View
protocol or their instances as views. However, from SwiftUI’s perspective, the content of nodes on the view value tree is what it considers to be views.
The Lifecycle of SwiftUI Views
Many articles describing the lifecycle of SwiftUI views usually depict it as the following sequence:
Initializing view instance — Registering data dependencies — Calling body
to compute the result — onAppear
— Destroying instance — onDisappear
With the above definition of views, we can identify the issues in this lifecycle description — it conflates two types of views and forcefully merges the lifecycles of different view types into one logical line.
In WWDC 2020’s Data Essentials in SwiftUI session, Apple specifically pointed out: The lifecycle of a view is separate from the lifecycle of the structure that defines it.
Therefore, we need to treat the views from the developer’s perspective and SwiftUI’s perspective separately, analyzing their lifecycles independently.
Lifecycle of Struct Instances Conforming to the View Protocol
Initialization
By adding print commands in the constructor of the struct, we can easily know when a SwiftUI instance is created. If you analyze the constructor’s print results carefully, you’ll find that the timing and frequency of creating struct instances far exceed your expectations.
To obtain the body value, it is necessary to first create an instance, but creating an instance does not necessarily require getting the body value!
- When SwiftUI generates the view value tree, it creates an instance to obtain its body result if no corresponding instance exists.
- In generating a new view value tree, even if there is a corresponding instance (which has not been destroyed), SwiftUI may still create a new instance. However, SwiftUI does not necessarily obtain the body result from the new instance; if the previous instance registered data dependencies, the view value tree might still obtain results from the original instance’s body.
- In a
NavigationView
, if a static target view is used in aNavigationLink
, SwiftUI will create instances for all target views, regardless of whether they are accessed or not. - In a
TabView
, SwiftUI creates instances for all views corresponding to the tabs from the beginning.
There are many similar situations. This explains why many developers encounter seemingly random multiple initializations of some views. This might be due to SwiftUI creating a new instance after destroying the first one, or creating a new instance without destroying the first one.
In short, SwiftUI will create any number of instances at any time based on its own needs. The only thing developers can do to adapt to this feature of SwiftUI is to make the constructor of the struct as simple as possible. Apart from necessary parameter settings, avoid any redundant operations. This way, even if SwiftUI creates extra instances, it won’t significantly burden the system.
Registering Data Dependencies
In SwiftUI, state (or data) is the driving force of the UI. To allow views to reflect changes in state, views need to register with their corresponding dependencies. Although we can declare dependencies in the struct’s constructor using specific property wrappers (like @State
, @StateObject
, etc.), I do not believe the work of registering data dependencies happens during the initialization phase. The main reasons are as follows:
- The burden of registering dependencies is not small. For instance,
@ObservableObject
requires heap allocation each time a dependency is created, which is resource-intensive and may risk data loss. If the work of registering dependencies were done in the constructor, it would contradict the principle of creating lightweight constructors. - Beyond property wrappers, SwiftUI also provides other ways for views to register dependencies, such as
onReceive
,onChange
,onOpenURL
,onContinueUserActivity
. These methods require parsing the content in the body to be completed. - As will be mentioned later, within the lifecycle of the view value tree, regardless of how many instances are created, only one copy of the dependencies is retained. When using a new instance, SwiftUI still associates the new instance with the existing dependencies.
Given these reasons, registering view dependencies should occur after initialization but before obtaining the body result.
Computing the Result with body
By adding code similar to the following in body
, we can get notified when SwiftUI calls the body of an instance:
let _ = print("update some view")
The computation of the body value occurs on the main thread, and SwiftUI must complete all calculations, comparisons, layouts, etc., within a rendering cycle. To avoid UI lag, the body should be designed as a pure function, creating only simple view descriptions within it, while delegating complex logic operations and side effects to other threads (for example, dispatching logic to other threads in the Store or using task
in the view to dispatch tasks to other threads).
Destruction
Structs do not provide a destructor method, but we can observe the approximate destruction timing of struct instances with code like this:
class LifeMonitor {
let name: String
init(name: String) {
self.name = name
print("\(name) init")
}
deinit {
print("\(name) deinit")
}
}
struct TestView: View {
let lifeMonitor: LifeMonitor
init() {
self.lifeMonitor = LifeMonitor(name: "testView")
}
}
Through observation, we can find that SwiftUI also does not have a uniform pattern in handling the destruction of struct instances.
For example, in code like this:
ZStack {
ShowMessage(text: "1")
.opacity(selection == 1 ? 1 : 0)
ShowMessage(text: "2")
.opacity(selection == 2 ? 1 : 0)
}
struct ShowMessage: View {
let text: String
let lifeMonitor: LifeMonitor
init(text: String) {
self.text = text
self.lifeMonitor = LifeMonitor(name: text)
}
var body: some View {
Text(text)
}
}
Each time selection
switches between 1 and 2, SwiftUI recreates two new instances and destroys the old ones.
However, in this code:
TabView(selection: $selection) {
ShowMessage(text: "1")
.tag(1)
ShowMessage(text: "2")
.tag(2)
}
SwiftUI only creates two instances of ShowMessage
initially. Regardless of how selection
is switched, TabView
will use only these two instances throughout.
SwiftUI may destroy instances at any time and create new ones, or it may keep instances for a longer time. In summary, assumptions about the timing and frequency of the creation and destruction of instances should be avoided.
Lifecycle of Views in the View Value Tree
Lifespan
Contrary to the completely uncertain lifespan of instances conforming to the View protocol, the lifecycle of views in the view value tree is much easier to determine.
Each view value has a corresponding identifier, and the combination of the view value and identifier represents a specific section of the view on the screen. When the Source of Truth changes, the view value also changes, but since the identifier remains the same, the view continues to exist.
Typically, SwiftUI creates corresponding views on the view value tree when it needs to render a certain area of the screen or when data from that area is needed for layout. When the view is no longer needed for layout or rendering, it will be destroyed.
In rare cases, even if some views are temporarily not needed for layout and rendering, SwiftUI, for efficiency reasons, may still retain them on the view value tree. For example, in List and LazyVStack, even if Cell views scroll off the screen and no longer participate in layout and rendering, SwiftUI still retains their data until the List or LazyVStack is destroyed.
@State
and @StateObject
have lifecycles consistent with the view’s lifecycle, and here, the view refers to the view in the view value tree. If interested, you can use @StateObject
to precisely determine the view’s lifecycle.
onAppear and onDisappear
To be accurate, the views in the view value tree, as values, do not have any lifecycle nodes other than creation and destruction. However, onAppear and onDisappear are indeed associated with these events in terms of behavior.
It’s important to note that the scope of the closures in onAppear and onDisappear is not the view that wraps them but the view they are attached to!
The official SwiftUI documentation describes onAppear and onDisappear as actions to perform when the view appears and disappears, respectively. This description closely matches the behavior of these modifiers in most scenarios. Thus, they are often seen as the SwiftUI equivalents of UIKit’s viewDidAppear and viewDidDisappear, presumed to occur only once in the lifecycle. However, a comprehensive analysis of their trigger timings reveals behaviors that do not fully align with this description. For instance, in the following scenarios, onAppear and onDisappear defy most expectations:
- In a ZStack, onAppear is triggered even if the view is not displayed, and onDisappear is not triggered even if the view disappears (is not displayed). The view remains in existence.
ZStack {
Text("1")
.opacity(selection == 1 ? 1 : 0)
.onAppear { print("1 appear") }
.onDisappear { print("1 disappear") }
Text("2")
.opacity(selection == 2 ? 1 : 0)
.onAppear { print("2 appear") }
.onDisappear { print("2 disappear") }
}
// Output
2 appear
1 appear
- In List or LazyVStack, the Cell view triggers onAppear when entering the screen and onDisappear when scrolling off the screen, and it can trigger onAppear and onDisappear multiple times during its existence.
ScrollView {
LazyVStack {
ForEach(0..<100) { i in
Text("\(i)")
.onAppear { print("\(i) onAppear") }
.onDisappear { print("\(i) onDisappear") }
}
}
}
- In ScrollView + VStack, onAppear is triggered even if the Cell view is not displayed on the screen.
ScrollView {
VStack {
ForEach(0..<100) { i in
Text("\(i)")
.onAppear { print("\(i) onAppear") }
.onDisappear { print("\(i) onDisappear") }
}
}
}
Similar examples abound, like with TabView, or setting the frame to zero.
From this, it’s evident that onAppear and onDisappear can be triggered multiple times during the view’s existence. The conditions for triggering onAppear and onDisappear are not solely based on whether the view appears or is visible.
Thus, I believe that whether the view participates in or affects its parent view’s layout should be the condition for triggering onAppear and onDisappear. This criterion explains the scenarios mentioned above:
-
In a ZStack, even if a layer is hidden, it necessarily affects the layout planning of the parent ZStack. Similarly, when switching a display layer to a hidden layer, it still participates in the layout, so all layers in the ZStack trigger onAppear at the beginning but do not trigger onDisappear.
-
In List and LazyVStack, for efficiency, SwiftUI retains the view of the Cell in the view value tree (thus the view continues to exist) even if it scrolls out of the display range. Therefore, when the Cell view enters the display range (affecting the container layout), it triggers onAppear, and when it
moves out of the display range (not affecting the container layout), it triggers onDisappear. This can be repeated during its existence.
Additionally, due to the different layout logic of List and LazyVStack (List has a fixed container height, LazyVStack has a variable height estimated downwards), their triggers for onDisappear differ. List triggers on both upper and lower sides, while LazyVStack only triggers on the lower side.
- In ScrollView + VStack, even if the Cell view does not appear in the visible area, it participates in the container’s layout from the beginning, hence triggering onAppear upon creation. However, no matter how much it scrolls, all Cell views continue to participate in the layout, so onDisappear is not triggered.
The parent view indeed calls the closures in onAppear and onDisappear based on whether the view affects its own layout, explaining why these two modifiers apply to the parent view rather than the view itself.
task
The task
modifier in SwiftUI has two forms of expression: one similar to onAppear
and another similar to a combination of onAppear
+ onChange
(refer to “Understanding SwiftUI’s onChange”).
The version similar to onAppear
can be seen as an asynchronous version of onAppear
. If the task execution time is short, the following code can achieve a similar effect:
.onAppear {
Task {
....
}
}
Many sources claim that task
is tied to the lifecycle of the view, but this isn’t entirely accurate. A more precise description would be that when the view is destroyed, a cancellation signal is sent to the closure in the task
modifier. Whether the task is actually cancelled is determined by the closure within task
.
struct ContentView: View {
@State var show = true
var body: some View {
VStack {
if show {
Text("Hello, world!")
.padding()
.task {
var i = 0
while !Task.isCancelled { // Try changing this line to while true {
try? await Task.sleep(nanoseconds: 1_000_000_000)
print("task:", i)
i += 1
}
}
.onAppear {
Task {
var i = 0
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 1_000_000_000)
print("appear:", i)
i += 1
}
}
}
}
Button("show") {
show.toggle()
}
}
}
}
Correlation Between the Two Lifecycles
In this section, we will explore the connections between the two lifecycles and summarize them.
For ease of explanation, the following text refers to “instances conforming to the View protocol” as “instances” and “views in the view value tree” as “views”.
- An instance must be created first to generate a view.
- The created instance may not necessarily be used to generate a view.
- Multiple instances might be created during the lifecycle of a view.
- Instances can be destroyed at any time during the lifecycle of a view.
- At least one instance is always maintained during the lifecycle of a view.
- The first instance that generates the view value will complete the establishment of dependencies.
- There is only one copy of the dependencies during the lifecycle of a view.
- Regardless of how many instances are created, only one instance at a time can connect to the dependencies during the lifecycle of the view.
- Dependencies serve as the Source of Truth for the view.
Understanding the Significance of SwiftUI View Lifecycle
SwiftUI attempts to de-emphasize the concept of view lifecycle, and in most scenarios, it indeed achieves its design goal. Developers can still work efficiently with SwiftUI code without understanding the content described above. However, a deeper understanding of the view lifecycle can help developers improve code efficiency in certain specific situations. Here are a few examples:
Lightweight Constructors
Many SwiftUI developers have already noticed the issue of struct instances being created multiple times. Especially after WWDC 2020 explicitly advised the creation of lightweight struct constructors, developers have started moving many tasks that were originally in constructors to the onAppear
method.
This significantly improves efficiency issues caused by multiple instance creations.
Executing Complex Tasks Only Once
However, onAppear
or task
may not be executed only once either. How can we ensure that some heavy tasks are performed only once in the view? By utilizing the characteristic that the lifecycle of @State
is consistent with the view’s lifecycle, we can effectively address this issue.
struct TabViewDemo1: View {
@State var selection = 1
var body: some View {
TabView(selection: $selection) {
TabSub(idx: 1)
.tabItem { Text("1") }
TabSub(idx: 2)
.tabItem { Text("2") }
}
}
}
struct TabSub: View {
@State var loaded = false
let idx: Int
var body: some View {
Text("View \(idx)")
.onAppear {
print("tab \(idx) appear")
if !loaded {
print("load data \(idx)")
loaded = true
}
}
.onDisappear{
print("tab \(idx) disappear")
}
}
}
// Output
tab 1 appear
load data 1
tab 2 appear
load data 2
tab 1 disappear
tab 1 appear
tab 2 disappear
tab 2 appear
tab 1 disappear
tab 1 appear
Reducing View Computations
In the previous section on the view value tree, we mentioned that when SwiftUI rebuilds the tree, if a node’s (view’s) Source of Truth has not changed, it will not be recalculated and will use the old value instead. Using this feature, we can split certain areas of the view struct into a form recognizable by the nodes (views created by structs conforming to the View protocol) to improve the refresh efficiency of the view tree.
struct UpdateTest: View {
@State var i = 0
var body: some View {
VStack {
let _ = print("root update")
Text("\(i)")
Button("change") {
i += 1
}
// Circle will be recalculated on each refresh
VStack {
let _ = print("circle update")
Circle()
.fill(.red.opacity(0.5))
.frame(width: 50, height: 50)
}
}
}
}
// Output
root update
circle update
root update
circle update
root update
circle update
Splitting out the Circle:
struct UpdateTest: View {
@State var i = 0
var body: some View {
VStack {
let _ = print("root update")
Text("\(i)")
Button("change") {
i += 1
}
UpdateSubView()
}
}
}
struct UpdateSubView: View {
var body: some View {
VStack {
let _ = print("circle update")
Circle()
.fill(.red.opacity(0.5))
.frame(width: 50, height: 50)
}
}
}
// Output
root update
circle update
root update
Conclusion
SwiftUI, as a young framework, is still not deeply understood by many. With the continuous improvement of official documentation and WWDC sessions, more principles and mechanisms behind SwiftUI will become known and mastered by developers.