3

Make @Observable Wrapper for Better State Control - DZone

 9 months ago
source link: https://dzone.com/articles/make-observable-wrapper-for-a-better-state-control
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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.

Dec. 14, 23 · Tutorial
Like (1)
465 Views

In 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).

Swift
struct Settings {
    var fontSize: Int
}

When using the @Observable macro, our Settings The model might look something like this:

Swift
@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.

Swift
@Observable
final class FirstViewModel {
    var settings: Settings = Settings(fontSize: 10)
    ...
}

And the FirstView:

Swift
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.

Swift
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.

Swift
@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:

Swift
@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:

Swift
@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:

Swift
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:

Swift
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:

Swift
@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:

Swift
@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:

Swift
@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:

Swift
@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:

Swift
final class SettingsState: ObservableState<Settings> { }

Not a huge difference, but just to write a bit less code is always better.

Swift
final class FirstViewModel {
    var settingsState: SettingsState
    ...
}

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK