SwiftUI — New @Observable macro
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 toObservable
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
- https://developer.apple.com/videos/play/wwdc2023/10149/
- https://developer.apple.com/documentation/swiftui/migrating-from-the-observable-object-protocol-to-the-observable-macro
- https://developer.apple.com/documentation/swiftui/managing-model-data-in-your-app
- https://developer.apple.com/documentation/Observation
- https://developer.apple.com/documentation/swiftui/bindable
- https://developer.apple.com/documentation/swiftui/state
- https://developer.apple.com/documentation/swiftui/environment