Lifecycle of SwiftUI Views

Published on

Get weekly handpicked updates on Swift and SwiftUI!

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:

Swift
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:

Swift
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 a NavigationLink, 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:

Swift
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:

Swift
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:

Swift
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:

Swift
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.
Swift
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.
Swift
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.
Swift
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:

Swift
.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.

Swift
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.

Swift
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.

Swift
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:

Swift
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.

Weekly Swift & SwiftUI insights, delivered every Monday night. Join developers worldwide.
Easy unsubscribe, zero spam guaranteed