Observation Framework in iOS 17

⋅ 7 min read ⋅ iOS 17 WWDC23 Swift 5.9 Observation

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.

Sponsor sarunw.com and reach thousands of iOS developers.

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.

  1. Passing data via an initializer.
  2. Passing data via an Environment.

Passing via an initializer

This might be the simplest way of passing the data.

  1. You just declare let or var in a view.
  2. 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.

  1. Using custom EnvironmentKey
  2. 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.

The view keeps track of an observable object and updates accordingly.
The view keeps track of an observable object and updates accordingly.

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.

Sponsor sarunw.com and reach thousands of iOS developers.

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 Share
Previous
Floating Action Button in SwiftUI

iOS doesn't have a Floating Action Button, but we can easily recreate it using what we have in SwiftUI.

Next
How to add a Toolbar in UINavigationController

Learn how easy it is to add a toolbar on a view controller in UIKit.

← Home