Controlling SwiftUI View updates with Observation
ObservableObject: An unwieldy approach
One of the major selling points of SwiftUI is the declarative approach to user interface design. By composing a hierarchy of views that depend on data models, SwiftUI automatically updates the affected parts of your interface without the need for explicit updates.
However, one of the biggest problems with SwiftUI has been with the fact that it does not know when a View
actually depends on the updated value or not. Take this example:
class User: ObservableObject {
@Published var age: Int
@Published var name: String
@Published var street: String
}
struct AgePicker: View {
@ObservedObject var user: User
var body: some View {
Slider(value: $user.age, label: { Text("Age") })
}
}
struct StreetPicker: View {
@ObservedObject var user: User
var body: some View {
TextField("Street", text: $user.street)
}
}
Now, if we add a Self._printChanges()
in the StreetPicker
body, we will see that it triggers when we drag the AgePicker
slider.
Hold on, what now? Why does changing age
trigger StreetPicker
to refresh?
Because ObservableObject
works by emitting a objectWillChange()
notifier to the view when any @Published
value inside it changes.
This mechanic is a bit counterintuitive, because it means that because we change one variable in User
, every view observing a User
will rerun its body regardless if the change was relevant to that particular view.
It is also pretty bad news for a complex SwiftUI app. If we end up observing an object from many different views, our whole app could exhibit performance issues when dragging a simple Slider to change a value that we only care about in one small part of the app.
We wrote Sensei Monitor in SwiftUI by taking great care to ensure that one data change does not force unnecessary refresh cycles. As a result, it's now the best performing menu bar System Monitor available for the Mac.
New @Observable Macro
At WWDC 2023, Apple adressed this issue by introducing the new @Observable and @Bindable macros as part of the new Observation framework. The new @Observable macro replaces the conformance to ObservableObject
and @Published
annotations for the values in our model, and the new @Bindable
property wrapper ensures that we can create bindings to the values.
But what is more important is that @Observable introduces a new mechanic for driving UI updates: it can automatically detect whether an update actually affects the view from which you are observing.
Let's see how adopting Observable would look for our model and views:
import Observation // Observable is imported separate from SwiftUI
@Observable class User {
var age: Int
var name: String
var street: String
}
struct AgePicker: View {
@Bindable var user: User
var body: some View {
Slider(value: $user.age, label: { Text("Age") })
}
}
struct StreetPicker: View {
@Bindable var user: User
var body: some View {
TextField("Street", text: $user.street)
}
}
This is already a bit cleaner, but most importantly, when we add the same Self._printChanges()
in the new StreetPicker
body, we will see that it no longer triggers when dragging the age slider!
In conclusion, this is a very critical improvement to SwiftUI and will probably resolve a lot of performance issues that developers have encountered while trying to implement SwiftUI in real-world apps.
Unfortunately, the new Observation framework requires iOS 17.0 / macOS 14.0 or later. This means that it will be a few years before most developers can benefit from these changes.
If you need to support older OS and want to learn strategies for constraining view updates in SwiftUI without using Observation, stay tuned for our next post on this topic!
Check out the demo for this example on our GitHub: https://github.com/Cindori/ObservableTests