Intro to Observation in SwiftUI
So far, you’ve learned how to manage state with struct values in @State and @Binding properties. While effective for many situations, you’ll sometimes need to store state in class objects. This section introduces managing state in SwiftUI using class.
If you’re unsure about the difference between struct and class, do a quick search and read up on value types and reference types. A high-level understanding of this will help you in this lesson.
When you store state in a class object in SwiftUI, you must make the class observable. Unlike value types like struct and enum, SwiftUI can’t detect changes in class properties automatically. This is why classes must be observable when storing state in SwiftUI, allowing SwiftUI to update a view’s body when a class object’s property changes.
You can hold class state objects in @State, @Bindable, @Environment, and plain var properties. Most often, you’ll use var, @Bindable, and @Environment properties. In this lesson, you’ll focus on using objects with @Bindable properties. If this is your first encounter with @Bindable, don’t worry; this lesson will introduce and explain its use and benefits.
Now that you know storing state in classes might be necessary, it’s time to learn how to make these classes observable.
Making a Class Observable
To make a class observable, use the @Observable macro from the Observation framework:
@Observable
final class MyViewsState {
var title: String
// ...
}
With this, SwiftUI can now track changes to properties in a MyViewsState object. This approach makes a class generally observable, so you can use observation outside SwiftUI if needed. Observation is used by SwiftUI but is implemented as a separate framework.
When to Use Class for State Management
Several situations call for @Observable class objects. You should start by building with structs for state and switch to classes when your needs exceed what structs can provide. Since making this switch depends on specific cases, detailing all scenarios requiring classes for state management is outside this lesson’s scope. However, you’ll tackle one very common situation next.
Editing Financial Entries
One common use case for classes is the list and list detail pattern, typical in iOS, where users move from a list of objects to a detail screen for an item and modify it. Changes to an item need to be reflected in the list when users return from the detail screen.
In this lesson, you’ll create a detail screen for a financial entry in the budget-tracking app, allowing users to edit entries from this screen. This requires moving from structs to classes for state management and using the @Observable macro from the Observation framework to make the state class observable.
Starter Project
To get started, open the starter Xcode project located at 03-leveraging-observation-for-shared-state-management/02-instruction/Starter/MyBudget.xcodeproj.
In this project, you’ll be working with three Swift files:
-
BudgetTrackerApp.swift: This file contains the app’s main structure. It initializes the app’s primary view and manages the overall data flow. During the video demo, you’ll integrate the
FinancialDataclass to hold and manage all financial entries globally, ensuring that changes are propagated across the entire app. -
FinancialEntryModel.swift: This file will define your data models. Initially,
FinancialEntryis a struct, but you’ll convert it into an observable class using the@Observablemacro. This transformation allows the app to track and respond to changes in financial entries. In the video demo, the file will come to house theFinancialDataclass, which aggregates all entries and computes totals for expenses and income. -
EditFinancialEntryView.swift: This view file is where users will edit individual financial entries. You’ll add a
@Bindableproperty forFinancialEntryto bind entry properties directly to UI elements in the form. This setup enables real-time updates to the entry as users modify values, ensuring the UI is always in sync with the underlying data model.
Together, these files create the core functionality of the app for editing and managing financial entries. They will use SwiftUI’s @Observable and @Bindable to ensure consistent and responsive state management.
Step 1: Making FinancialEntry Mutable
Currently, FinancialEntry is a struct with immutable properties. To allow editing, you need to make its properties mutable. This step is essential because mutable properties can be updated in the EditFinancialEntryView.
Open FinancialEntryModel.swift, and change the following properties in FinancialEntry from let to var:
var amount: Double
var category: String
var isExpense: Bool
Changing let to var makes each property of FinancialEntry mutable. This is a prerequisite for later allowing users to modify these values directly in a form.
Step 2: Preparing EditFinancialEntryView for Editing
You need to pass a FinancialEntry instance to EditFinancialEntryView for editing. This step sets up the view to accept an entry.
Open EditFinancialEntryView.swift, and add a var entry: FinancialEntry property:
var entry: FinancialEntry
By adding var entry: FinancialEntry, EditFinancialEntryView can now display and edit a passed FinancialEntry. This sets the stage for linking each entry in the list to its edit view.
At the bottom of the same file, update the SwiftUI preview to pass a sample entry:
#Preview {
EditFinancialEntryView(entry:
FinancialEntry(
id: UUID(),
amount: 100,
category: "Groceries",
isExpense: true
))
}
This allows the preview to render in the canvas in Xcode.
Step 3: Linking Entries to EditFinancialEntryView
To navigate from a financial entry in the list to its edit view, you’ll implement the destination of NavigationLink in ContentView.
Open BudgetTrackerApp.swift, and find the ForEach loop inside the List in the body of ContentView. Replace the TODO comment in NavigationLink with the destination EditFinancialEntryView(entry: entry):
EditFinancialEntryView(entry: entry)
This code enables navigation from each financial entry in the list to its edit view, passing the selected entry as a parameter.
To enable editing of the amount in EditFinancialEntryView, you’ll initially try using a TextField. This attempt will fail because there’s no binding to mutable state.
Open EditFinancialEntryView.swift. Inside the Form, replace the TODO comment with the following TextField to edit the amount:
TextField("Amount", value: $entry.amount, format: .number)
You know how to bind to mutable state — $ to the rescue! Build the project… and compilation fails. An issue arises because you can’t create bindings to just any property.
Why Is Class Required Here?
Recall from Lesson 2 that bindings are created from @State properties. The list view holds a @State property of an array of struct entries. Based on what you’ve learned, you might expect to pass a binding to one of the array’s entries into the detail view, allowing it to modify the entry. However, creating a binding to a single entry within an array isn’t possible in SwiftUI.
This is why you need to use classes for items in list-to-detail UIs. There’s no way to pass a struct item from an array into a detail view in a manner that lets you modify the struct and reflect those changes in the list. You have to use a class to share the state of a single entry between the detail view and the list.
In the budget-tracking app, the solution is to use a class for FinancialEntry. Remember, classes must be marked as @Observable for SwiftUI to detect and respond to property changes. So, the FinancialEntry class needs to be @Observable.
Once you change FinancialEntry to a class, each FinancialEntry object will be stored in the list view’s array and passed to the edit view as an observable object. This setup allows the edit view to modify the properties directly, with those changes reflected in the entry list view.
You’ll see this in action in the next step.
Step 4: Transitioning to a Class-Based Model With @Observable
Convert FinancialEntry to a class, and make it observable using the @Observable macro. This allows SwiftUI to track and react to changes in the class’s properties.
Open FinancialEntryModel.swift, and change FinancialEntry from struct to final class:
final class FinancialEntry: Identifiable {
Implement the necessary initializer in FinancialEntry:
init(id: UUID, amount: Double, category: String, isExpense: Bool) {
self.id = id
self.amount = amount
self.category = category
self.isExpense = isExpense
}
And add the @Observable macro above the class definition:
@Observable
final class FinancialEntry: Identifiable {
This change enables the FinancialEntry objects to be observable, allowing SwiftUI to update the UI when any FinancialEntry properties change.
Build the project. Notice how the compiler throws the same error as before. This time, the build fails because FinancialEntry is not bindable even though FinancialEntry is now an object and is observable.
What Is @Bindable and Why Do You Need It?
What’s going on here? Why can’t you create a binding for the FinancialEntry‘s amount property using $entry.amount? While making a class observable with @Observable allows SwiftUI to detect and respond to changes in an object’s properties, @Observable doesn’t enable you to create bindings for those properties. @Observable is designed solely for SwiftUI to track changes, which is why the project doesn’t build successfully yet, even though you’ve switched to using a class marked as @Observable.
To pass a class object’s property like FinancialEntry‘s amount into a subview’s @Binding property, you must use the @Bindable property wrapper for the class property in the parent view.
Consider the amount text field as an example:
TextField("Amount", value: $entry.amount, format: .number)
The code inside TextField has a @Binding String property, so the TextField can modify a String stored in a parent view. Because the entry property in EditFinancialEntryView isn’t a @State property, you can’t create a binding to FinancialEntry’s amount property. You need to use the @Bindable property wrapper on the entry property in EditFinancialEntryView like this: @Bindable var entry: FinancialEntry. Remember, entry is now a class type.
In summary, to create a binding to a class object’s property, the object must use the @Bindable property wrapper. This wrapper enables you to create bindings for observable object properties and pass those bindings into subviews that have @Binding properties.
With this in mind, fix the compiler error by making the entry object property bindable in EditFinancialEntryView.
Step 5: Enabling Bindings With @Bindable
Open EditFinancialEntryView.swift, and add the @Bindable property wrapper to the entry property:
@Bindable var entry: FinancialEntry
Build the project. This time, the build succeeds! Now that FinancialEntry is an @Observable class and entry in EditFinancialEntryView is marked as @Bindable, the entry’s amount property is now properly bound to the amount TextField.
Step 6: Completing the Edit Form With Proper Data Binding
Now that FinancialEntry is observable and bindable, it’s time to complete the edit form.
Inside Form, in the same EditFinancialEntryView.swift file, add the decimal pad keyboard type view modifier to the amount text field:
.keyboardType(.decimalPad)
Now, add the rest of the edit form controls below the amount TextField:
TextField("Category", text: $entry.category)
Toggle(isOn: $entry.isExpense) {
Text("Is Expense")
}
These controls are bound to the properties of FinancialEntry. Changes made in the form will directly modify the FinancialEntry object, which is reflected across the app due to the observable nature of the class.
Now, build and run the app.
You’ll see that you can add entries, navigate to their edit view, and modify their details. This interactive experience showcases the power of class-based state management in SwiftUI when combined with the Observation framework.
@Binding Versus @Bindable
Learning about @Bindable right after @Binding might seem confusing. Here’s a clear overview to help you understand when to use @Bindable:
-
Structs: For
structs, you use@Stateto make astruct’s properties bindable. This allows you to create bindings using$, which can then be passed into@Bindingproperties in a subview. -
Classes: For
classes, you use@Bindableto make aclass’s properties bindable. Similarly, this enables you to create bindings using$, which can be passed into@Bindingproperties in a subview.
Learning More About @Observable Objects
Not only can you store and pass @Observable object instances using view properties, but you can also store them in SwiftUI’s environment. However, learning how to do this is beyond the scope of this lesson. To learn more about using Observation in SwiftUI, visit Apple’s Managing model data in your app sample code and documentation.
Note on Observation in iOS 17
This lesson focuses on state management using Observation, which is available in iOS 17 and above. It’s important to note that this lesson doesn’t cover prior class-type state management concepts such as @ObservableObject, @ObservedObject, @StateObject, and @EnvironmentObject. As you progress in your learning journey, you may encounter different state management approaches used in earlier versions of iOS. However, this lesson is tailored to provide you with an understanding of only the latest practices in state management with SwiftUI.
Demo: Separating Logic Outside of SwiftUI
You just saw how to use Observation to display and modify objects in a detail view from a list. Observation also facilitates the separation of logic from your SwiftUI views, which simplifies unit testing. In the upcoming video demo, you’ll implement logic in a new observable class to calculate the totals for expense and income items and display these totals in a new section in the list view.