Understanding SwiftUI's View Update Mechanism: Starting from a TimelineView Update Issue

Published on

Get weekly handpicked updates on Swift and SwiftUI!

In SwiftUI, the automatic view update mechanism allows us to easily build responsive user interfaces. However, sometimes views may not update as we expect. This article explores SwiftUI’s view update mechanism through a seemingly simple but representative TimelineView update issue.

The Problem

I recently received an email from a reader seeking help with an intriguing issue: when using SwiftUI’s TimelineView, two side-by-side emojis exhibited different behaviors.

The problematic code is roughly as follows:

Swift
let emojis = ["😀", "😬", "😄", "🙂", "😗", "🤓", "😏", "😕", "😟", "😎", "😜", "😍", "🤪"]

struct EmojiDemo: View {
    var body: some View {
        TimelineView(.periodic(from: .now, by: 0.2)) { timeline in
            HStack(spacing: 120) {
                let randomEmoji = emojis.randomElement() ?? ""
                Text(randomEmoji)
                    .font(.largeTitle)
                    .scaleEffect(4.0)

                RightEmoji()
            }
        }
    }

    struct RightEmoji: View {
        // let id: Int = .random(in: 0 ... 100_000) // Uncommenting this line makes the emoji update
        var body: some View {
            let randomEmoji = emojis.randomElement() ?? ""

            Text(randomEmoji)
                .font(.largeTitle)
                .scaleEffect(4.0)
        }
    }
}

This code exhibits a strange phenomenon: the emoji on the left continuously updates over time (randomly switching within the emoji array), while the emoji on the right (encapsulated in the RightEmoji view) remains static. Interestingly, if we add an apparently unrelated random variable in RightEmoji (even if we don’t use it), the right emoji starts updating normally.

Although I’ve mentioned the principles behind similar issues in previous articles, I notice that readers still feel confused. Given that such questions are quite common in the community, I find it necessary to delve into this typical case to explore SwiftUI’s response and update mechanisms and understand what exactly causes this phenomenon.

Analyzing View Concepts in SwiftUI

Before deeply understanding SwiftUI’s update mechanism, we need to clarify three core concepts: view types, view declarations, and view type instances. These concepts may seem simple but are crucial to understanding how SwiftUI works.

View Types

When discussing “views” in SwiftUI, we usually refer to a type that conforms to the View protocol. The most common way is:

Swift
struct DemoView: View {
    var body: some View {
        Text("Hello World")
    }
}

While structs are the most commonly used, SwiftUI doesn’t limit us to structs. Any value type can become a view type, such as enums:

Swift
enum EnumView: View {
    case hello
    var body: some View {
        Text("\(self)")
    }
}

Interestingly, view types don’t necessarily have to primarily describe the UI. We can make any type gain the ability to become a view type through extensions:

Swift
struct Student {
    var name: String
    var age: Int
    var height: Double
    var weight: Double

    func sayHello() {
        print("Hello, I'm \(name)")
    }

    var bmi: Double {
        weight / (height * height)
    }
}

extension Student: View {
    var body: some View {
        Text("Hello, I'm \(name), \(age) years old")
    }
}

View Declarations

View declarations are code snippets where developers describe the presentation of the interface. Although most commonly done in the body property of a view type, SwiftUI provides various flexible declaration methods:

Swift
// Global function
func hello() -> some View {
    Text("Hello World")
}

// Global variable
let world = Text("World")

// Type property
@MainActor
enum MyViews {
    static let redRectangle: some View = Rectangle().foregroundStyle(.red)
}

// Enum
enum MyEnumView: String, View {
    case hello
    case world

    var body: some View {
        Text(rawValue)
    }
}

struct CombineView: View {
    var body: some View {
        VStack {
            hello()
            world
            MyViews.redRectangle
            MyEnumView.hello
        }
    }
}

It’s important to note that a view declaration is not a fixed pixel-level description but an abstract expression. SwiftUI determines the final rendering based on multiple factors (such as state, layout space, color mode, hardware specifications, etc.). For SwiftUI, a view declaration is essentially a value obtained by parsing the declaration code.

View Type Instances

To obtain the value of a view declaration, SwiftUI needs to create instances of view types. The process is roughly as follows:

Swift
// SwiftUI internal workflow illustration
let demoViewInstance = DemoView()        // Create view type instance
saveInstanceValue(demoViewInstance)      // Save instance value
let demoViewValue = demoViewInstance.body // Get view declaration value
saveViewValue(demoViewValue)             // Save view declaration value

SwiftUI saves two key values:

  • The value of the view type instance
  • The value of the view declaration

Some readers may wonder why we need to save the value of the view type instance. This is because, when there is no explicit signal to recompute, SwiftUI needs to decide whether to recompute the view declaration value by comparing changes in the view instance values. This mechanism is one of the cores of SwiftUI’s view update process, which we will discuss in detail later.

Response and Re-evaluation of View Declaration Values

SwiftUI’s Response Mechanism

Besides being a declarative framework, SwiftUI is also a reactive framework that automatically responds to events and invokes corresponding logic. The most common event types are user interactions and system events.

Swift
struct OnTapDemo: View {
    @State var count = 0
    var body: some View {
        let _ = print("Evaluating View Declaration Value")
        Text("Count: \(count)")
        Text("Tap Me")
            .onTapGesture {
                print("hello world")
            }
    }
}

In the code above, we demonstrate how SwiftUI responds to user tap events:

  • Developers use the onTapGesture method to inject response code through the SwiftUI framework.
  • When the user taps “Tap Me,” this code is called, and “hello world” is printed to the console.

However, after running the code, we find that apart from the initial load (when SwiftUI calls the body property of OnTapDemo to get the view declaration value), the body is not called again, no matter how many times we tap. This is because, in the onTapGesture closure, we did not perform operations recognized by SwiftUI that would affect the result of the view declaration value.

The following code injects response code to a timer Publisher through onReceive. Similar to the code above, although “hello” is continuously printed to the console, it does not cause the view declaration value to be recalculated.

Swift
struct OnReceiveDemo: View {
    @State var timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
    var body: some View {
        let _ = print("Evaluating View Declaration Value")
        Text("Hello")
            .onReceive(timer) { _ in
                print("hello")
            }
    }
}

This means that adding response logic in the view code does not necessarily cause the view declaration value to change, nor does it necessarily cause SwiftUI to re-evaluate (compute) the view declaration value.

Conditions for Re-evaluation of View Declaration Values

Apart from computing the view declaration value when the view is first loaded into the view tree, SwiftUI only re-evaluates the view declaration value under specific conditions to avoid unnecessary overhead. These conditions include:

  • Explicit evaluation requests triggered by property wrappers provided by SwiftUI (e.g., @State, @StateObject, @Environment, etc.).
  • Changes in the value of the view type instance.

Regardless of the condition, the starting point must be in response to some event. In other words, an event must occur before re-evaluation can be triggered.

Let’s adjust the OnTapDemo code by modifying the value of count in onTapGesture:

Swift
struct OnTapDemo: View {
    @State var count = 0
    var body: some View {
        let _ = print("Evaluating View Declaration Value")
        Text("Count: \(count)")
        Text("Tap Me")
            .onTapGesture {
                count += 1
            }
    }
}

Now, when a tap event occurs, since count (based on @State) has changed, it meets the conditions for SwiftUI to re-evaluate the view declaration value. We can see that the body of the view is called again, and the Count displayed on the screen changes.

In SwiftUI, computing the view declaration value is a recursive process. It starts from the current view and traverses down the view tree in order unless a branch explicitly indicates that it does not need to continue computing downwards.

For example, in OnTapDemo, when re-evaluating its declaration value, print("Evaluating View Declaration Value") will inevitably execute, but this does not mean that all child views will have their declaration values re-computed.

SwiftUI will recreate instances of each child view and compare them with the previously saved instance values. Whether changes occur between the two determines whether to continue computing the view declaration value along this child view.

Text("Count: \(count)"), because the value of count has changed (passed through the constructor parameter), causes its instance value to change. Therefore, this child view will inevitably be re-computed, and we can see the displayed value of Count has changed.

On the other hand, Text("Tap Me"), since the instance values are consistent before and after, SwiftUI will not re-compute the view declaration value of this child view.

To better demonstrate this process, we can observe by constructing child views and adding more output statements:

Swift
struct OnTapDemo: View {
    @State var count = 0
    var body: some View {
        let _ = print("Evaluating View Declaration Value")
        Text("Count: \(count)")
        Text("Tap Me")
            .onTapGesture {
                count += 1
            }
        SubView1()
        SubView2(count: count)
    }
}

struct SubView1: View {
    init() {
        print("SubView1 init")
    }

    var body: some View {
        let _ = print("SubView1 body update")
        Text("No changes")
    }
}

struct SubView2: View {
    let count: Int
    init(count: Int) {
        self.count = count
        print("SubView2 init")
    }

    var body: some View {
        let _ = print("subview2 body update")
        Text("Count Changes: \(count)")
    }
}

By observing the console output of this code, we can clearly see how SwiftUI evaluates whether to re-compute the declaration value of child views:

  • When a tap event occurs, the count in OnTapDemo changes, causing SwiftUI to re-evaluate its view declaration value.
  • During the evaluation process, when processing SubView1, a new instance of the SubView1 view type is recreated and compared (console outputs “SubView1 init”).
  • SwiftUI finds that the new instance value of SubView1 is consistent with the previously saved instance value, so it does not continue to re-evaluate the view declaration value of SubView1, stopping its processing (ending recursion in this branch).
  • When processing SubView2, a new instance is also recreated (console outputs “SubView2 init”).
  • Since the constructor parameter count of SubView2 has changed, causing the new instance value to be inconsistent with the previously saved instance value, SwiftUI will re-evaluate the view declaration value of SubView2 (console outputs “SubView2 body update”) and replace the original instance value with the new instance value for future comparisons.
  • SwiftUI will continue processing recursively in SubView2 according to the above rules.

Through this code, developers should understand why SwiftUI, after responding to an event, needs to reconstruct view instances in the branch, regardless of whether the view has changed.

For performance considerations, SwiftUI uses methods similar to memcmp to compare differences between two instance values in default scenarios.

Ending Recursion and the Starting Point of Updates

Some readers might wonder: If SwiftUI, when comparing downward from an update starting point, finds that the view instance values of its child views haven’t changed and thus ends the recursive operation, does that mean its grandchild views won’t be updated—even if the state they depend on has changed during this update cycle?

Obviously not. In a SwiftUI update cycle, multiple states might change, or multiple views might directly depend on the changed state. SwiftUI collectively considers these views that need their view declaration values re-evaluated and treats each one as a starting point for an update operation.

Simply put, in a multi-layer view structure like A -> B -> C -> D, if views A and C need to update due to state changes, while the view instance values of B and D haven’t changed, SwiftUI will separately use A and C as starting points for the current update computation, performing recursive operations independently.

Returning to Our Problem

Now, let’s revisit the TimelineView code provided:

Swift
struct EmojiDemo: View {
    var body: some View {
        TimelineView(.periodic(from: .now, by: 0.2)) { timeline in
            HStack(spacing: 120) {
                let randomEmoji = emojis.randomElement() ?? ""
                Text(randomEmoji)
                    .font(.largeTitle)
                    .scaleEffect(4.0)

                RightEmoji()
            }
        }
    }

    struct RightEmoji: View {
        // let id: Int = .random(in: 0 ... 100_000) // Uncommenting this line makes the emoji update
        var body: some View {
            let randomEmoji = emojis.randomElement() ?? ""

            Text(randomEmoji)
                .font(.largeTitle)
                .scaleEffect(4.0)
        }
    }
}

TimelineView is a view container provided by SwiftUI that generates a series of events based on a preset time sequence and automatically responds to these events in the closure.

  • When an event occurs, the code let randomEmoji = emojis.randomElement() ?? "" in the TimelineView closure is called.
  • Since the value of randomEmoji changes, the new instance value of Text(randomEmoji) used to display the left emoji also changes accordingly. SwiftUI re-evaluates the view declaration value of this Text, so we see the left emoji changing.
  • For the RightEmoji view, since its instance value does not change, SwiftUI does not re-evaluate its view declaration value, and the right emoji does not change.

When we add a random variable in RightEmoji, the new instance value changes. Even if this random variable is not actually used, SwiftUI will still re-evaluate the view declaration value of RightEmoji. At this point, the right emoji will also change with the occurrence of time events.

What We Learned

By exploring the view concepts and response update mechanisms in SwiftUI, developers should grasp the following key points:

  • Response code does not necessarily cause the view declaration value to be re-computed: Adding response logic in view code does not mean that the view declaration value will be re-evaluated as a result.
  • Re-computation of the view declaration value requires event triggering: SwiftUI only re-evaluates the view declaration value under specific conditions (such as state changes). This process must be triggered by some event.
  • Handle the construction process of view types carefully: Avoid performing time-consuming or complex operations in the constructor of view types. Because regardless of whether the view declaration value needs to be re-computed, SwiftUI may create instances of the view type multiple times.
  • Optimize the computation process of the view declaration value: The computation of the view declaration value is a recursive process. By appropriate optimization, such as reducing unnecessary nested computations, you can effectively reduce computational overhead.
  • Rationally split the view structure: Encapsulating the view declaration in a separate view type allows SwiftUI to better identify which views do not need to be re-computed, thereby improving the efficiency of view updates.

Familiarity with these internal working mechanisms of SwiftUI will help developers write higher-performance and more robust SwiftUI applications. Interested readers can refer to related articles at the bottom to further understand the implementation details and optimization techniques of SwiftUI.

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