Observation x SwiftUI

SwiftUI's state management just got way simpler, thanks to the new @Observable macro.

Observable

Observable is the new way to say "You can observe this", and its a macro built right into the language. It's not a part of SwiftUI, or Combine, but Swift.

Lets take the example of a simple counter class to demonstrate this. First up, lets see what that would look like yesterday.

import Combine

class Counter: ObservableObject {
    @Published var count: Int = 0
}
import Combine

class Counter: ObservableObject {
    @Published var count: Int = 0
}

Next, lets look at the usage of the new @Observable macro. We need to import Observation, add Observable to the class, and then remove Published.

import Observation

@Observable
class Counter {
    var count: Int = 0
}
import Observation

@Observable
class Counter {
    var count: Int = 0
}

Well that doesn't look too different, but there's some special behaviour to consider here. If we were to add more properties to Counter, we'd have to add @Published to each, but with Observable, we just have to make the variables public and they get observabilty by default.

Observing

The most obvious way to observe something like this, would be SwiftUI.

The first thing to consider is getting this data into our view - we don't use @ObservedObject anymore, and we won't be able to use @StateObject either. This leaves us with the option of storing it as a @State variable, or simply passing it through from our top level app.

In this case, we'll have it passed in from the top.

struct ContentView: View {
    let counter: Counter
    ...
}
struct ContentView: View {
    let counter: Counter
    ...
}
@main
struct ObservableDemoApp: App {
    private let counter: Counter = .init()

    var body: some Scene {
        WindowGroup {
            ContentView(counter: counter)
        }
    }
}
@main
struct ObservableDemoApp: App {
    private let counter: Counter = .init()

    var body: some Scene {
        WindowGroup {
            ContentView(counter: counter)
        }
    }
}

Observing our property is as easy as with an observable object. Lets quickly build out a view for our counter.

struct ContentView: View {
    let counter: Counter

    var body: some View {
        NavigationStack {
            VStack {
                Text(counter.count.formatted())
                    .monospaced()

                HStack(spacing: 32) {
                    Button(action: {
                        counter.count -= 1
                    }, label: {
                        Image(systemName: "minus.circle.fill")
                    })

                    Button(action: {
                        counter.count += 1
                    }, label: {
                        Image(systemName: "plus.circle.fill")
                    })
                }
            }
            .font(.largeTitle)
            .fontDesign(.rounded)
            .fontWeight(.bold)
            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
        }
        .animation(.spring, value: counter.count)
    }
}
struct ContentView: View {
    let counter: Counter

    var body: some View {
        NavigationStack {
            VStack {
                Text(counter.count.formatted())
                    .monospaced()

                HStack(spacing: 32) {
                    Button(action: {
                        counter.count -= 1
                    }, label: {
                        Image(systemName: "minus.circle.fill")
                    })

                    Button(action: {
                        counter.count += 1
                    }, label: {
                        Image(systemName: "plus.circle.fill")
                    })
                }
            }
            .font(.largeTitle)
            .fontDesign(.rounded)
            .fontWeight(.bold)
            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
        }
        .animation(.spring, value: counter.count)
    }
}

When we tap on the buttons, the view updates just like we'd expect from @Published.

To validate that the view is updating when we expect, we can always use the trick let _ = Self._printChanges() at the top of our view body to print changes out to the console. If you add that now, you'll see every button tap fires a reload.

Observable includes something clever to avoid un-needed view updates.

Lets add a new property to our Counter.

@Observable
class Counter {
    var count: Int = 0
    var string: String = "hello"
}
@Observable
class Counter {
    var count: Int = 0
    var string: String = "hello"
}

Next lets add a button to change that property.

Button(action: {
    counter.string = UUID().uuidString
}, label: {
    Text("Change")
})
Button(action: {
    counter.string = UUID().uuidString
}, label: {
    Text("Change")
})

Tap on that button, and you'll notice nothing prints from _printChanges(). That's because SwiftUI and Observable play nicely and only setup observation for the stuff you actually observe.

Bindings

Lets look at a common use case for a @State or @Binding property, a text field.

Here's a simple view with a state property and a text field for you to enter your name.

struct BindingObservables: View {
    @State var name: String = ""

    var body: some View {
        Form {
            TextField(text: $name, label: {
                Text("Name")
            })
        }
    }
}
struct BindingObservables: View {
    @State var name: String = ""

    var body: some View {
        Form {
            TextField(text: $name, label: {
                Text("Name")
            })
        }
    }
}

Converting that to use observation like we have earlier actually throws an error - we don't have a binding anymore.

"Cannot find '$name' in scope.

struct BindingObservables: View {
    var name: String = ""

    var body: some View {
        Form {
            TextField(text: $name, label: {
                Text("Name")
            })
        }
    }
}
struct BindingObservables: View {
    var name: String = ""

    var body: some View {
        Form {
            TextField(text: $name, label: {
                Text("Name")
            })
        }
    }
}

Luckily there's something available to solve this - @Bindable. To use this, we need to clean up our code a little.

First, we can move our name into a model, that we mark as Observable.

@Observable
public final class BindingModel {
    public var name: String = ""
}
@Observable
public final class BindingModel {
    public var name: String = ""
}

Next, lets get this into our view, and mark it as @Bindable - which then allows us to use $model.name like we'd expect if this was an observable object.

struct BindingObservables: View {
    @Bindable var model: BindingModel

    var body: some View {
        Form {
            TextField(text: $model.name, label: {
                Text("Name")
            })
        }
    }
}
struct BindingObservables: View {
    @Bindable var model: BindingModel

    var body: some View {
        Form {
            TextField(text: $model.name, label: {
                Text("Name")
            })
        }
    }
}

Thanks for reading this brief intro to observation!

You can find the demo app here.

If you fancy reading a little more, or sharing your experiences, I’m @SwiftyAlex on twitter.