Make @Observable Wrapper for Better State Control in Swift
The New Apple Observation framework has some limitations. Let's take a look at a way to keep a value type for the model and use the Observation framework in the Swift application.
Join the DZone community and get the full member experience.
Join For FreeIn iOS 17, Apple introduced a new Observation
framework which provides an implementation of the observer design pattern.
The following is a snippet from the Apple documentation outlining this feature:
The Observation frameworks provides the following capabilities:
- Marking a type as observable
- Tracking changes within an instance of an observable type
- Observing and utilizing those changes elsewhere, such as in an app’s user interface
In simple terms, we can create our custom type using the @Observable
macro and react to any changes in its properties. This approach works seamlessly with SwiftUI and addresses several challenges related to tracking nested types that we faced previously.
Now, let's consider a scenario where we are developing an E-Book reading application. On the initial screen, there is an opened book along with an option to display a second screen showing some book settings, where the user can change the font size (we take just one property to keep things simple for this example).
struct Settings {
var fontSize: Int
}
When using the @Observable
macro, our Settings
The model might look something like this:
@Observable
class Settings {
var fontSize: Int
init(fontSize: Int) {
self.fontSize = fontSize
}
}
We can utilize our model in the first view to render a book with an adjustable font size. Additionally, we can inject it into the second view using the @Bindable
property wrapper, allowing us to update the font size as needed.
While this approach seems promising, the first view will likely require more functionality than just the Settings model. Therefore, we might create a FirstViewModel
object that handles state management and other business logic related to the E-Book reading screen.
@Observable
final class FirstViewModel {
var settings: Settings = Settings(fontSize: 10)
...
}
And the FirstView
:
struct FirstView: View {
@State private var settingsPresented = false
private let viewModel: FirstViewModel
init(viewModel: FirstViewModel) {
self.viewModel = viewModel
}
var body: some View {
VStack {
...
}
.sheet(isPresented: $settingsPresented) {
SecondView(settings: viewModel.settings)
.presentationDetents([.medium])
}
}
}
To keep things straightforward, we'll simply inject the Settings
model into the SecondView
. In this view, we'll include a "+"
button that allows users to increase the font size.
struct SecondView: View {
@Bindable var settings: Settings
var body: some View {
VStack {
...
Button(
action: {
settings.fontSize += 1
},
label: {
Text("+")
}
)
...
}
}
}
The appearance of the Settings
model looks good and serves our purposes. However, reality is more complicated than this example.
One problem is that our Settings
model is no longer a struct
. In real-world applications, you typically have a persistence layer and use ORMs for your models, you also retrieve data from the backend, as well as using Codable, etc. The combination of these different functionalities can make your model object messy when mixed. Therefore, I want my idle model to be a value type rather than a reference one, and use additional wrappers or extensions for such functionality. If I wish to change the Settings
model back to a struct, but still keep the @Observable
macro, it will not be possible.
@Observable
struct Settings {
var fontSize: Int
init(fontSize: Int) {
self.fontSize = fontSize
}
}
I will get the following error in the example above:
Error: @Observable' cannot be applied to struct type 'Settings'
There is no way to use @Observable
with value types, and it makes sense since observation wouldn't work with a value type because it would make a copy of the data every time.
However, we have our FirstViewModel
set up as @Observable
, so we can just use the original struct Settings
in that context. Unfortunately, now we are getting an error in the SecondView
for our binding @Bindable var settings
:
Error: 'init(wrappedValue:)' is unavailable: The wrapped value must be an object that conforms to Observable
We have several options to fix this issue, and I will focus on these two:
1. Inject the entire FirstViewModel
as @Bindable
, which still conforms to Observable
.
2. Wrap the Settings
in an object that can be injected.
Option one works in our very simple example, but it would not be a good approach for real applications. We would have responsibilities that SecondView
shouldn't be aware of, and we don't want to break this isolation too much.
Let's take a look at one of the options for the second approach. Here is a simple object that can be used as a state management wrapper for our model:
@Observable
class ObservableState<Item> {
var item: Item
init(item: Item) {
self.item = item
}
}
With such a generic type, we will be able to use it in the view model as follows:
@Observable
final class FirstViewModel {
var settingsState: ObservableState<Settings>
init() {
settingsState = ObservableState<Settings>(item: Settings(fontSize: 10))
}
...
}
Now we can use our state to inject it into the SecondView
:
struct FirstView: View {
@State private var settingsPresented = false
private let viewModel: FirstViewModel
...
var body: some View {
VStack {
...
}
.sheet(isPresented: $settingsPresented) {
SecondView(settings: viewModel.settingsState)
.presentationDetents([.medium])
}
}
}
The SeconView
will have almost the same implementation as before:
struct SecondView: View {
private var state: ObservableState<Settings>
init(state: ObservableState<Settings>) {
self.state = state
}
var body: some View {
VStack {
...
Button(
action: {
state.item.fontSize += 1
},
label: {
Text("+")
}
)
...
}
}
}
The updated approach using ObservableState
serves our needs to keep the model as a value-type object. However, we can now also use this wrapper for additional functionality that the observation framework doesn't provide for free.
Let's say, for example, that we store our Settings
in some persistent storage or our backend. This means we need to update it when the user changes the font size. Using the previous approach, we would need to implement this by injecting an additional closure or delegate that is responsible for notifying us FirstViewModel
when the update is finished and we want to update Settings
in the storage/backend. Since we don't want to update it every time the user taps on the "+"
button, we can't use any kind of subscription to the value update (we will address this case later; it's also not clear with the Observation
framework).
Let's use our new ObservableState
object to add functionality directly to the state:
@Observable
class ObservableState<Item> {
var item: Item
var onFinish: (() -> Void)?
init(item: Item, onFinish: (() -> Void)? = nil) {
self.item = item
self.onFinish = onFinish
}
}
In the FirstViewModel
we can use this closure and have a logic for storing the settings:
@Observable
final class FirstViewModel {
var settingsState: ObservableState<Settings>
init() {
settingsState = ObservableState<Settings>(
item: Settings(fontSize: 10),
onFinish: {
// Store the updated settings
}
)
}
...
}
NOTE: This is just an example use case, and most likely you will use self-objects there. So you will need a separate function to inject the onFinish
closure when the view is loaded, and also use [weak self]
it if you still create it in a way that was described above but outside of the init for proper memory management.
In the second view, we just need to call state.onFinish?()
when the user is done with font size editing.
Another improvement we can make is to react to our Settings
changes in real time inside the FirstViewModel
. The Observation
framework works great with SwiftUI, and when the user updates the font size, it automatically updates the FirstView
. But what if we need to run some logic every time the fontSize
value changes? Or what if we need the same functionality completely outside of the SwiftUI flow? The use of the Observation
framework is not limited to SwiftUI only, but it doesn't provide us with such functionality for free.
By using withObservationTracking(\_:onChange:)
, we can subscribe to the property change of the @Observable
object. However, the onChange
will be called only once, and we will need to use recursion there to subscribe again every time we get onChange
a call. This approach may seem like a hack and doesn't look like some recommended out-of-the-box solutions.
With our ObservableState
wrapper, we can encapsulate an item update check:
@Observable
class ObservableState<Item> {
var item: Item {
didSet {
onChange?()
}
}
var onFinish: (() -> Void)?
private var onChange: (() -> Void)?
init(
item: Item,
onChange: (() -> Void)? = nil,
onFinish: (() -> Void)? = nil
) {
self.item = item
self.onChange = onChange
self.onFinish = onFinish
}
}
In that case, we can handle any Settings
change in onChange
closure:
@Observable
final class FirstViewModel {
var settingsState: ObservableState<Settings>
init() {
settingsState = ObservableState<Settings>(
item: Settings(fontSize: 10),
onChange: {
// Run some logic on every update of fontSize
},
onFinish: {
// Store the updated settings
}
)
}
...
}
There are no additional changes needed in SecondView
. They onChange
will work out of the box.
These are just a few examples of how such ObservableState
a concept can be used and why such an approach may bring some value.
One last change we can make is to use an additional type for every state to make it more readable in the view model:
final class SettingsState: ObservableState<Settings> { }
Not a huge difference, but just to write a bit less code is always better.
final class FirstViewModel {
var settingsState: SettingsState
...
}
Opinions expressed by DZone contributors are their own.
Comments