Observation Framework in iOS 17
Table of Contents
In SwiftUI, data is a source of truth. SwiftUI view would adapt to the data changes without our intervention.
To fulfill that promise, Apple required us to use many languages features together, such as the ObservableObject
protocol and property wrappers like @Published
, @EnvironmentObject
, and @StateObject
.
These language features become more complicated and challenging for newcomers or experienced UIKit developers.
In iOS 17, Apple introduced a new and simpler way to make a view response to data changes. All of the goodness lies in the new framework, Observation
.
In this article, we will learn how to use this new framework.
What is Observation Framework
Observation is a new framework introduced in WWDC 2023 (iOS 17, Swift 5.9, Xcode 15).
It is a framework that provides all the necessary tools to implement the observer design pattern in Swift.
The good news is, if you use SwiftUI, you probably don't need to care about what's inside this framework since Apple does all the hard work for us.
You can easily support sarunw.com by checking out this sponsor.
Localization Buddy: Easiest way to localize and update App Store metadata.
How to Declare an Observable Object
The only thing you need to do is marked a class that you want to be observable with the new Swift Macro, @Observable
.
@Observable
class Store {
var count: Int = 0
}
By annotating your class with the @Observable
macro, the class becomes observable by SwiftUI view.
Create the source of truth
After we have an observable object, we must decide who owns this data.
Based on the data, it can belong to a view or an entire application.
Regardless of who owns the data, you can create it the same way using a @State
property wrapper.
In the following example, we create an instance of Store
at the app's level since we will use the same model for an entire application. So, we need only one copy per application.
@Observable
class Store {
...
}
@main
struct ExampleApp: App {
@State private var store = Store()
var body: some Scene {
...
}
}
If the object is some kind of view's state, which means to use within a view, you can declare it inside a view where the lifetime of the data will depend on the lifetime of the view.
@Observable
class Store {
...
}
struct ContentView: View {
@State private var store = Store()
var body: some View {
...
}
}
We use the same property wrapper, @State
, whether it is an app or view data.
Share model data throughout a view hierarchy
SwiftUI view is a function of the data. Having a single source of truth means all the views that rely on the same data will show consistent information.
In the last section, we learn how to create a source of truth. In this section, we will learn how to pass that data to other views.
We have two ways to share data with other views.
- Passing data via an initializer.
- Passing data via an Environment.
Passing via an initializer
This might be the simplest way of passing the data.
- You just declare
let
orvar
in a view. - Then you pass an observable object when you initialize the view.
struct ContactCard: View {
// 1
let user: User
var body: some body {
Text(user.name)
}
}
struct ContactView: View {
@State private var users = [...]
var body: some body {
List(users) { user in
// 2
ContactCard(user: user)
}
}
}
@Observable
class User {
...
}
1 We declare an observable object we want to use within the view.
2 Then, we inject the data on an initialization.
Using @Environment property wrapper
Passing model data to subview using an initializer is straightforward, but it only suits apps with shallow view hierarchies.
If you have data that need by most views and subviews that sit deep down in the view hierarchy, it is better to add model data to the view's environment.
You can think of the view's environment as a singleton containing all values that can be used within views.
Every view can access environment values using the @Environment
property wrapper.
You can add the observable model to the environment in two ways.
- Using custom EnvironmentKey
- Add model data directly (new in iOS 17)
Using custom EnvironmentKey
The first way to set model data to a view's environment is by using a custom EnvironmentKey.
To do that, we need to create a new environment key. You create a type that conforms to the EnvironmentKey
protocol. The only requirement for this protocol is you define a default value for the key.
struct CustomStoreKey: EnvironmentKey {
static var defaultValue = Store()
}
Then we extend EnvironmentValues
to include a newly created model. EnvironmentValues
is a collection of environment values accessible from an entire view hierarchy.
In this example, we add a new value, store
.
extension EnvironmentValues {
// 1
var store: Store {
get { self[CustomStoreKey.self] }
set { self[CustomStoreKey.self] = newValue }
}
}
1 Every view in a hierarchy can access this view's environment can access this value via the key path, store
.
To read this value, we define a local variable with the @Environment
property wrapper with the key path to the custom environment value. In this case, store
.
struct ContentView: View {
// 1
@Environment(\.store) private var store
var body: some View {
Text("Count: \(store.count)")
Button("+1") {
store.count += 1
}
}
}
1 This will read the default store value that we defined in CustomStoreKey
.
If you want to provide a custom store, you can set it via environment(_:_:)
modifier.
@main
struct ExampleApp: App {
@State private var myStore = Store()
var body: some Scene {
WindowGroup {
// 1
ContentView()
.environment(\.store, myStore)
}
}
}
1 This will set myStore
to ContentView
and its subviews.
Set a view's environment without defining a custom environment value
New in iOS 17, you can set a value to view's environment without defining a custom environment value.
You do this with the help of the new modifier, environment(_:)
.
We can add an observable object to a view's environment without any key path.
@main
struct ExampleApp: App {
@State private var myStore = Store()
var body: some Scene {
WindowGroup {
ContentView()
.environment(myStore)
}
}
}
To retrieve the instance from the environment, we use the same
property wrapper, @Environment
, but we provide the model data type instead of providing a key path to the environment value.
struct ContentView: View {
@Environment(Store.self) private var store: Store
var body: some View {
Text("Count: \(store.count)")
Button("+1") {
store.count += 1
}
}
}
This new way of setting environment values is easier. You don't have to define new EnvironmentKey
and EnvironmentValues
, but there is one important thing you should be aware of.
That is, there is a chance that the environment value of that type will be nil
if you forget to set it via the environment(_:)
modifier.
You will get a runtime error if you try to access the value and it doesn't exist in the environment.
In cases where this environment value can be nil
, it is better to declare it as optional.
struct EnvView: View {
// 1
@Environment(Store.self) private var store: Store?
var body: some View {
if let count = store?.count {
Text("Count: \(count)")
Button("+1") {
store?.count += 1
}
} else {
Text("N/A")
}
}
}
1 Declare type as an optional (Store?
) to prevent the runtime error.
Observe changes in a SwiftUI view
Once each view gets access to an observable object, it is just a matter of using it.
A SwiftUI view automatically creates a dependency on an observable object when we read a property of the object inside the view's body
.
When a tracked property changes, SwiftUI updates the view.
@Observable
class Store {
var count: Int = 0
}
struct ContentView: View {
var store: Store
var body: some View {
VStack {
// 1
Text("Count: \(store.count)")
Button("+1") {
// 2
store.count += 1
}
}
}
}
1 The count
property is read in the view's body, so the dependency is created for this particular property.
2 When we update the count
, the view automatically updates.
You can see that the process of making an object become observable is very easy and concise.
The great thing is observable object work for both stored and computed properties.
The computed property, doubleCount
, can also be observed in the following example.
@Observable
class Store {
var count: Int = 0
var doubleCount: Int {
return count * 2
}
}
You can easily support sarunw.com by checking out this sponsor.
Localization Buddy: Easiest way to localize and update App Store metadata.
Working with Binding
As you can see, a view can support data changes with the Observation framework without using property wrappers or bindings.
But there are still components that expect a binding type (Binding
) before it can change the value, e.g., TextField
.
When you are working with those components, you need to use the @Bindable
property wrapper instead of var
and let
.
struct ContentView: View {
// 1
@Bindable var store: Store
var body: some View {
// 2
TextField("Name", text: $store.name)
}
}
1 We need @Bindable
since we want to use name
as an argument for TextField
2.
You can also use the @Bindable
property wrapper on a local variable if it makes sense for your case.
struct ContentView: View {
var store: Store
var body: some View {
@Bindable var bindableStore = store
TextField("Name", text: $bindableStore.name)
}
}
Read more article about iOS 17, WWDC23, Swift 5.9, Observation, or see all available topic
Enjoy the read?
If you enjoy this article, you can subscribe to the weekly newsletter.
Every Friday, you'll get a quick recap of all articles and tips posted on this site. No strings attached. Unsubscribe anytime.
Feel free to follow me on Twitter and ask your questions related to this post. Thanks for reading and see you next time.
If you enjoy my writing, please check out my Patreon https://www.patreon.com/sarunw and become my supporter. Sharing the article is also greatly appreciated.
Become a patron Buy me a coffee Tweet ShareFloating Action Button in SwiftUI
iOS doesn't have a Floating Action Button, but we can easily recreate it using what we have in SwiftUI.
How to add a Toolbar in UINavigationController
Learn how easy it is to add a toolbar on a view controller in UIKit.