View Invalidation
In SwiftUI, "re-drawing", "invalidating", or "updating" a View typically refers to two things:
-
The
bodyproperty of theViewwill be computed, then diffed -
If the
Viewis aUIViewRepresentableorUIViewControllerRepresentable, theupdateandsizeThatFitsfunctions will be invoked.
Both of these can be expensive:
-
Computing a large deeply nested body property can take some time, especially if done frequently. This can also cause cascading invalidations throughout the View tree.
-
Updating a leaf view (e.g a
UIViewRepresentable) can also be expensive, as well as thesizeThatFitsmethod (e.g, it might call out tosystemLayoutSizeFittingmultiple times)
We obviously want to minimize these invalidations in our code.
How does SwiftUI decide to "evaluate" a view?
MyView(x: x) // When will `MyView` change?
To decide if a View should be "invalidated", SwiftUI compares the properties on the view struct.
SwiftUI doesn't care what properties are actually used — it just compares the properties that are defined.
For example, our view below will be "re-evaluated" every time it's compared, because the x property will always be different — even though the x property is not even referenced.
struct MyView: View {
let x = UUID()
var body: some View { EmptyView() }
}
How are properties compared?
Here is a pseudo table describing how property types are compared:
| Property Type | Method |
|---|---|
| Equatable conforming type | SwiftUI compares the values directly using their Equatable function implementation. |
| Primitive value type | These primitive value types should conform to Equatable already — e.g, String, Int, Bool, etc. They should be compared. |
| struct | Compares each stored property recursively |
| AnyObject | SwiftUI uses the pointer, e.g something like
|
| Closure | Special handling! |
A few learnings here:
-
If you make a non-equatable property always equatable — it will never reload/redraw your view when its changed
-
Reference types (e.g
@Observablemacro classes) are great as comparing their properties will always be considered the same (===) — yet it can be used to locally re-draw specific views (I will explain soon).
How closures work
Above, I mentioned closures have special handling. Here is how they work:
Closures that capture state
If your closure captures a tracked state property (e.g., @State, or a property from an @Observable macro, etc.) — the View will ALWAYS reload — even if the state property hasn't changed:
Here, MyView will always be "updated" / "invalidated" every time the body of the parent runs, because its escaping closure references a state property.
Intuitively, you'd expect MyView to only reload when the count property actually has changed — but this is not the case.
This means, any time you have an escaping closure that captures a state tracked property, it can have huge negative performance impact:
@State private var count = 0
var body: some View {
MyView {
print(count)
}
}
Closures that don't capture state
If your closure doesn't capture any @State or @Observable macro class property, etc — the closure will always be considered the "same", and it will essentially never be compared when diffing the views.
@State private var count = 0
var body: some View {
MyView {
print("Hello world")
}
}
Avoiding large invalidations
For sake of example — say we have a large SwiftUI views in a body property:
struct MyView: View {
@State private var count: Int = 0
var body: some View {
Text("Count: \(count)").onTapGesture { count += 1 }
MyRow("1")
MyRow("2")
// ...
MyRow("N")
}
}
Anytime the count property changes, it will cause the body to be recomputed.
This will cause every single view (e.g MyRow) to be compared on each tap change.
Instead, we can use reference types to break-up the invalidation (as reference types are always equal ===):
@Observable
@MainActor
class ViewModel {
var count: Int = 0
}
struct MyView: View {
@State private var viewModel = ViewModel()
var body: some View {
Counter(viewModel: viewModel)
MyRow("1")
MyRow("2")
// ...
MyRow("N")
}
}
struct Counter: View {
let viewModel: ViewModel
var body: some View {
Text("Count: \(viewModel.count)").onTapGesture { viewModel.count += 1 }
}
}
Here, we can see when the view model's count changes — it only invalidates the body of the Counter view — not the body of the MyView.
Furthermore, if the body of MyView was ever invalidated — when Counter is compared, it will never be "invalidated" / "re-drawn" as the viewModel property would simply be compared viewModel === viewModel and thus there is no change.
Conclusion
What I found most interesting from this, is that:
- Closures that track state always re-invalidate a view
- We can use reference types strategically to design how our view trees will be invalidated