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:
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:
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:
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:
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:
// 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:
// 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.
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.
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
:
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:
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
inOnTapDemo
changes, causing SwiftUI to re-evaluate its view declaration value. - During the evaluation process, when processing
SubView1
, a new instance of theSubView1
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 ofSubView1
, 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
ofSubView2
has changed, causing the new instance value to be inconsistent with the previously saved instance value, SwiftUI will re-evaluate the view declaration value ofSubView2
(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:
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 theTimelineView
closure is called. - Since the value of
randomEmoji
changes, the new instance value ofText(randomEmoji)
used to display the left emoji also changes accordingly. SwiftUI re-evaluates the view declaration value of thisText
, 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.