SwiftUI: Observable Macro in Detail
The new @Observable
macro introduced at WWDC 2023 simplifies SwiftUI’s state management by enhancing how state is observed and maintained. Unlike pre iOS 17 the previous @StateObject
or @ObservedObject
(based on the Combine framework), @Observable
removes the need for publishers like objectWillChange
, automatically tracking dependencies when an observable object’s property is accessed in a view’s body.
Let’s break down how the @Observable
macro works by walking through the mechanics behind it. This will help explain how SwiftUI efficiently tracks changes in objects and automatically updates the UI.
Key Steps in How the @Observable
Macro Works:
Transforming a Property into a Computed Property:
When you declare a property inside an @Observable
class, Swift transforms that property from a simple stored property to a computed property behind the scenes.
@Observable final class Model {
var value = 0
}
- The
value
property above is transformed by the@Observable
macro into a computed property like this:
@Observable final class Model {
var value: Int {
get {
access(keyPath: \.value)
return _value
}
set {
withMutation(keyPath: \.value) {
_value = newValue
}
}
}
@ObservationIgnored private var _value = 0
}
access(keyPath: \.value)
: This records the access to thevalue
property using a key path.withMutation(keyPath: \.value)
: This records any changes made to thevalue
property and informs SwiftUI that the property has changed.@ObservationIgnored
: The macro adds a private_value
property to act as a backing store for the computed property. This property is not tracked directly by SwiftUI.
access
and withMutation
:
The methods access
and withMutation
are responsible for notifying the SwiftUI observation system whenever the property is accessed or mutated.
internal nonisolated func access<Member>(keyPath: KeyPath<Model, Member>) {
_$observationRegistrar.access(self, keyPath: keyPath)
}
internal nonisolated func withMutation<Member, T>(
keyPath: KeyPath<Model, Member>,
_ mutation: () throws -> T
) rethrows -> T {
try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation)
}
withMutation
: This is called whenever thevalue
property is modified. It tells SwiftUI that a change occurred, and the view needs to update._$observationRegistrar
: This internal registrar tracks which properties were accessed or mutated and which views are observing those properties.
The Observation Registrar:
The observation registrar tracks all the observers interested in changes to particular properties of the object. It keeps track of which views depend on which properties, ensuring that views are notified when relevant data changes.
Connecting Object Properties to Views:
The magic of connecting properties to views happens through a global function called withObservationTracking
. This function monitors which properties were accessed while rendering a view's body, and it sets up an observation that triggers a UI update whenever those properties change.
withObservationTracking {
view.body
} onChange: {
view.needsUpdate()
}
Here’s how it works:
apply
(the first closure): Executes the view's body and records which properties were accessed.onChange
(the second closure): Registered as a listener for changes to any of the properties accessed in theapply
closure. When those properties change, this closure is called to mark the view for an update.
SwiftUI Example with @Observable Macro
@Observable final class CounterModel {
var value = 0
}
- This model class uses the
@Observable
macro to track thevalue
property. - There’s no need for
@Published
orobjectWillChange
publishers like in older implementations. - The
value
property changes will automatically be observed by any view that accesses it.
Observable With @State Property Wrapper
struct CounterView: View {
@State private var model = CounterModel()
var body: some View {
VStack {
Text("Current Value: \(model.value)")
.font(.largeTitle)
Button("Increment") {
model.value += 1
}
.padding()
}
}
}
struct ContentView: View {
var body: some View {
CounterView()
}
}
#Preview {
ContentView()
}
- We use
@State
to couple the lifetime of theCounterModel
instance to the view’s lifecycle. - The
Text
view inside the body accessesmodel.value
, which creates a dependency between the view and the model.
Key Concepts Explained:
- Automatic Observation: When you use
@Observable
in the model, SwiftUI automatically tracks access to themodel.value
in the view’s body. This means that whenevermodel.value
changes, the view will update accordingly without needing explicit subscriptions. - Simplified Property Access: You no longer need to wrap properties in
@Published
. The@Observable
macro does all the heavy lifting to observe changes to the properties in your model. - Efficient Updates: Only the specific properties that are accessed in the view’s body will cause updates. If
CounterModel
had multiple properties and you only use one in the view, changes to other properties won’t trigger unnecessary re-renders.
Problem : Using @State
for an Object Passed from the Outside
We create a CounterViewModel
class to model the counter state and then pass it to a Counter
view. We'll incorrectly use @State
for the external model.
import SwiftUI
import SwiftUI
@Observable final class CounterViewModel {
var value = 0
init(value: Int) {
self.value = value
}
}
struct Counter: View {
@State var model: CounterViewModel // Incorrect usage of @State for an external object
var body: some View {
VStack {
Text("Current Value: \(model.value)") // Displays the model's value
Button("Increment") {
model.value += 1 // Increments the model's value
}
}
.padding()
}
}
struct ContentView: View {
@State private var counterModel = CounterViewModel(value: 0)
var body: some View {
VStack {
Counter(model: counterModel) // Passing an external model to the Counter view
Button("Reset Counter to 5") {
counterModel = CounterViewModel(value: 5) // Attempt to reset the model
}
}
}
}
Expected Behavior:
- The
Counter
view shows the current value of thecounterModel
and increments it with a button. - The “Reset Counter to 5” button should reset the counter’s value to 5.
Problem in Action:
- Initial Rendering:
- The
ContentView
initializes thecounterModel
with a value of0
. - The
Counter
view displays"Current Value: 0"
. - Pressing the “Increment” button increments the counter to
1
,2
, etc.
2. Click “Reset Counter to 5” Button:
- You would expect the
Counter
view to reset its value to5
when you press the "Reset Counter to 5" button, but it doesn't happen. - Instead, the
Counter
view continues displaying the old value and increments it, because the@State
property in theCounter
view is holding onto its own copy of themodel
.
Why Does This Happen?:
As discussed in the section about State article , the problem here is that at the time the initializer runs, the view doesn’t yet have identity. So, all this initializer does is change the initial value of the @State property, but it doesn’t affect the state that’s being used when the view is already onscreen.
The same applies if we only try to pass in a value from the outside and construct, for example, a view model object within the initializer:
struct Counter: View {
@State var model: CounterViewModel
init(value: Int) {
self.model = CounterViewModel(value: value)
}
var body: some View {
Button("\(model.value)") { model.value += 1}
}
}
Again, if the counter view is already onscreen and we pass a different value to the initializer, this won’t change the title of the button. Only the initial value of the @State property will be changed to the new CounterViewModel instance, which will only be used the next time the view is inserted into the render tree.
Solution: Use @Binding
or Pass the Object Directly
To fix this issue, you can either:
- Pass the object directly: If you don’t need the view to modify the
model
's state, just pass it as a regular property (no need for@State
). - Use
@Binding
: If you need to modify the object’s state and reflect those changes outside of the view, use@Binding
.
#Preview {
ContentView()
}
import SwiftUI
@Observable final class CounterViewModel {
var value = 0
init(value: Int) {
self.value = value
}
}
struct Counter: View {
@Binding var model: CounterViewModel // Incorrect usage of @State for an external object
// var model: CounterViewModel
var body: some View {
VStack {
Text("Current Value: \(model.value)") // Displays the model's value
Button("Increment") {
model.value += 1 // Increments the model's value
}
}
.padding()
}
}
struct ContentView: View {
@State private var counterModel = CounterViewModel(value: 0)
var body: some View {
let _ = Self._printChanges()
VStack {
Counter(model: $counterModel) // Passing an external model to the Counter view
Button("Reset Counter to 5") {
counterModel = CounterViewModel(value: 5) // Attempt to reset the model
}
}
}
}
Correct Behavior:
- When the “Reset Counter to 5” button is pressed, the
Counter
view now correctly updates to display5
. - The
Counter
view properly increments the newcounterModel.value
after it is reset.
Observable Without Property Wrapper
You can also pass an external instance of the model without using @State
. Here’s an example:
@Observable final class CounterModel {
var value = 0
static let shared = CounterModel()
}
struct CounterView: View {
var model: CounterModel
var body: some View {
VStack {
Text("Current Value: \(model.value)")
.font(.largeTitle)
Button("Increment") {
model.value += 1
}
.padding()
}
}
}
struct ContentView: View {
var body: some View {
CounterView(model: CounterModel.shared) // Pass external model
}
}
#Preview {
ContentView()
}
Key Points:
- No @State: We’re no longer using
@State
here. Instead, the model is passed in from outside, as demonstrated by passingCounterModel.shared
. - Independent Object Lifetime: In this case, the lifetime of
CounterModel
is managed independently of the view, allowing it to persist across different view instances.
Summary :
- Old Approach: Previously, you would use
@StateObject
or@ObservedObject
to track changes in objects. These usedCombine
publishers and required explicit management of subscriptions (e.g.,objectWillChange
). - New Approach: With
@Observable
, tracking dependencies is simplified. You no longer need@Published
or@StateObject
. SwiftUI tracks the specific properties you use in the view’s body, ensuring only the necessary updates happen. @State
: For data that should be private to a view and whose lifetime is tied to the view’s lifecycle use@State.
- Normal Properties: For data or objects passed in from outside the view, which are independent of the view’s lifecycle and may persist across multiple views don’t use
@State.
By understanding these key differences and using the @Observable
macro, you can write more efficient, readable, and easier-to-manage SwiftUI code.