SwiftUI — New @Observable macro

Ondrej Kvasnovsky
3 min readMay 17, 2024

--

Starting with iOS 17, iPadOS 17, macOS 14, tvOS 17, and watchOS 10, SwiftUI introduces a new and powerful feature: the @Observable macro.

This macro brings a Swift-specific implementation of the observer design pattern, offering significant improvements over the old ObservableObject.

With @Observable, we can track optionals and collections, utilize existing data flow primitives like State and Environment, and update views based on specific observable properties, enhancing app performance.

Making Model Data Observable

To make a data model observable, apply the @Observable macro, which generates the necessary observation support at the compile time.

import Foundation

@Observable
class Record: Identifiable, Hashable {

let measuredAt: Date
var value: Double

init(measuredAt: Date, value: Double) {
self.measuredAt = measuredAt
self.value = value
}

func hash(into hasher: inout Hasher) {
hasher.combine(measuredAt)
}

static func == (lhs: Record, rhs: Record) -> Bool {
lhs.measuredAt == rhs.measuredAt
}
}

@Observable macro also makes our class to conform to Observable protocol.

Observing Model Data in a View

A view forms a dependency on an observable data model object when its body property reads a property of the object. SwiftUI then updates the view when any of these tracked properties change.

import SwiftUI

// we can also have global object that are observed
var baseRecord: Record = Record(
measuredAt: Date.now,
value: 0.0
)

struct RecordView: View {

var record: Record

var formatter: DateComponentsFormatter {
let formatter = DateComponentsFormatter()
formatter.allowedUnits = [.day, .hour, .minute]
formatter.unitsStyle = .abbreviated
return formatter
}

var body: some View {
let duration: TimeInterval = baseRecord.measuredAt.distance(to: record.measuredAt)
HStack {
Text("\(formatter.string(from: duration) ?? "-")")
Spacer()
Text("\(record.value.formatted(.number.precision(.fractionLength(0...1)))) units")
}
}
}

#Preview {
RecordView(
record: Record(
measuredAt: Date.now.addingTimeInterval(TimeInterval(199999)),
value: 10.0
)
)
}

Dependency Tracking with Collections

@Observable macro also supports tracking changes in collections. When a view reads a collection property, it updates whenever the collection changes (e.g., inserting, deleting, moving items).

import Foundation

@Observable
class RecordStore {

var records: [Record] = []

init() {
createRandomRecords()
}

func createRandomRecords() {
let numberOfRecords = 10

records.removeAll()

for _ in 0..<numberOfRecords {
let randomDate = Date(timeIntervalSince1970: TimeInterval.random(in: Date.now.timeIntervalSince1970...Date.now.timeIntervalSince1970.advanced(by: 1000000)))
let randomValue = Double.random(in: 0...100)
let record = Record(measuredAt: randomDate, value: randomValue)
records.append(record)
}

records.sort { $0.measuredAt < $1.measuredAt }
}
}

Creating and Managing the Source of Truth

To create and store the source of truth for model data, declare a private variable and initialize it with an instance of an observable data model type, then wrap it with a @State property wrapper:

import SwiftUI

@main
struct ObservableApp: App {

@State private var recordStore = RecordStore()

var body: some Scene {
WindowGroup {
RecordsView()
.environment(recordStore)
}
}
}

By wrapping recordStore with @State, we tell SwiftUI to manage the storage of the instance, ensuring a single source of truth.

Using @Bindable for Two-Way Data Binding

The @Bindable property wrapper in SwiftUI is used to create a binding to an observable property within a view.

It enables two-way data binding, meaning that changes made to the UI component are reflected in the underlying data model and vice versa.

import SwiftUI

struct RecordEditView: View {

@Environment(\.dismiss) private var dismiss

@Bindable var record: Record

var body: some View {
VStack {
TextField("Value", value: $record.value, format: .number)
.textFieldStyle(.roundedBorder)
.onSubmit {
dismiss()
}
}
.padding()
}
}

#Preview {
RecordEditView(
record: Record(measuredAt: Date.now, value: 10)
)
}

@Bindable is especially useful for forms or editable content where the view needs to directly modify the data.

Sharing Model Data Throughout a View Hierarchy

We can share model data throughout a view hierarchy using either direct passing or the environment.

Example 1: Direct Passing of Model Data

In this example, we pass the recordStore instance directly to the child views.

import SwiftUI

struct RecordsView2: View {

var recordStore: RecordStore

var body: some View {
NavigationStack {
List(recordStore.records) { record in
NavigationLink(destination: RecordEditView(record: record)) {
RecordView(record: record)
}
}
.navigationTitle("Records")
}
}
}

#Preview {
RecordsView2(recordStore: RecordStore())
}

Example 2: Sharing Model Data through the Environment

In this example, we use the @Environment property wrapper to share the RecordStore instance throughout the view hierarchy.

import SwiftUI

struct RecordsView: View {

@Environment(RecordStore.self) private var recordStore

var body: some View {
NavigationStack {
List(recordStore.records) { record in
NavigationLink(value: record) {
RecordView(record: record)
}
}
.navigationDestination(for: Record.self) { record in
@Bindable var record = record
// ^^^ this is what we have been all waiting for :-D
RecordEditView(record: record)
}
.navigationTitle("Records")
}
}
}

#Preview {
RecordsView()
.environment(RecordStore())
}

References to learn more

--

--

No responses yet