SwiftUI: Observable Macro in Detail

abdul ahad
7 min readSep 14, 2024

--

Photo by Samsung Memory on Unsplash

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 the value property using a key path.
  • withMutation(keyPath: \.value): This records any changes made to the value 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 the value 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 the apply 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 the value property.
  • There’s no need for @Published or objectWillChange 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 the CounterModel instance to the view’s lifecycle.
  • The Text view inside the body accesses model.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 the model.value in the view’s body. This means that whenever model.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 the counterModel and increments it with a button.
  • The “Reset Counter to 5” button should reset the counter’s value to 5.

Problem in Action:

  1. Initial Rendering:
  • The ContentView initializes the counterModel with a value of 0.
  • 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 to 5 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 the Counter view is holding onto its own copy of the model.

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:

  1. 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).
  2. 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:

  1. When the “Reset Counter to 5” button is pressed, the Counter view now correctly updates to display 5.
  2. The Counter view properly increments the new counterModel.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 passing CounterModel.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 used Combine 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.

Reference:

--

--

abdul ahad
abdul ahad

Written by abdul ahad

A software developer dreaming to reach the top and also passionate about sports and language learning

No responses yet