I'd like to end this series by taking advantage of the recently introduced Observable macro. The changes we need to make are small and focused, but we also run into a few issues. Let's get started.

Preparing the Project

Swift macros were introduced in Swift 5.9 so you need to have Xcode 15 or later installed if you'd like to follow along. SwiftUI provides support for Observation as of iOS 17, so we first need to raise the deployment target. Open the project in Xcode 15, click the project in the Project Navigator on the left, and select the Thunderstorm target from the list of targets. Set the deployment target to iOS 17.

Adopting Observation

Open the Find Navigator on the left and search the project for the ObservableObject protocol. Four classes conform to the ObservableObject protocol, LocationViewModel, LocationsViewModel, LocationCellViewModel, and AddLocationViewModel.

Migrating LocationViewModel

Let's start with the LocationViewModel class. Open LocationViewModel.swift and remove the reference to the ObservableObject protocol.

import Swinject
import Foundation

@MainActor
final class LocationViewModel {

	...

}

To adopt Observation, we apply the Observable() macro to the class declaration. By applying the Observable() macro, the LocationViewModel class supports Observation and it conforms to the Observable protocol.

import Swinject
import Foundation

@MainActor
@Observable final class LocationViewModel {

	...

}

The compiler isn't happy with this change, though. It throws an error that doesn't make much sense at first glance. The compiler complains that the Published property wrapper cannot be applied to the state property because it is a computed property. This is confusing because the state property is a stored property, not a computed property.

The compiler is correct, though. I won't cover macros in detail in this series. What you need to know is that a macro changes the code it is applied to. This happens before the code is compiled and that can make it difficult and confusing to debug issues like this. The code we wrote looks fine. It is the code the Observable() macro generates that is causing a problem.

Simply put, the Observable() macro converts the stored state property to a computed state property. It generates setters and getters for the computed state property. With this in mind, the error the compiler throws makes more sense.

At the time of writing, the Observable() macro cannot be used in combination with the Published property wrapper. While this might be a temporary limitation that is addressed in a future release, we need to deal with it now.

The good news is that we no longer need to apply the Published property wrapper to the state property. By adopting Observation, changes of the state property are automatically detected and propagated. There is no longer a need for us to use the Published property wrapper in the LocationViewModel class.

private(set) var state: State = .fetching

We still run into an error when we build the Thunderstorm target. Open LocationView.swift and navigate to the declaration of the viewModel property. The compiler throws another error because the ObservedObject property wrapper can only be applied to a property declaration if the type of the property conforms to the ObservableObject protocol. That is no longer true, though.

The change we need to make is simple. We remove the ObservedObject property wrapper from the property declaration. Because the LocationViewModel class supports Observation, we don't need to apply the ObservedObject property wrapper. As I mentioned earlier, SwiftUI automatically tracks the observable properties the view's body reads directly. Not only is this convenient, it is also more performant than the previous implementation that used the ObservableObject protocol.

import SwiftUI

struct LocationView: View {

    // MARK: - Properties

    var viewModel: LocationViewModel

	...

}

Build the Thunderstorm target one more time to make sure we successfully migrated from the ObservableObject protocol to Observation.

Migrating LocationCellViewModel

Open LocationCellViewModel.swift, remove the reference to the ObservableObject protocol, and apply the Observable() macro to the class declaration.

import Foundation

@MainActor
@Observable final class LocationCellViewModel: Identifiable {

	...

}

Navigate to the weatherData property declaration and remove the Published property wrapper.

private var weatherData: WeatherData?

Open LocationCell.swift and remove the ObservedObject property wrapper from the viewModel property declaration.

import SwiftUI

struct LocationCell: View {

    // MARK: - Properties

    var viewModel: LocationCellViewModel

	...

}

Migrating LocationsViewModel

Open LocationsViewModel.swift, remove the reference to the ObservableObject protocol, and apply the Observable() macro to the class declaration.

import Combine
import Foundation

@MainActor
@Observable final class LocationsViewModel {

	...

}

Navigate to the locationCellViewModels property declaration and remove the Published property wrapper.

private(set) var locationCellViewModels: [LocationCellViewModel] = []

There is one problem we need to address. In the start() method, the view model relies on the Published property we just removed. We could use the assign(to:on:) method to set the locationCellViewModels property on self, the view model, but that would cause a retain cycle.

The solution is simple, though. We invoke the sink(receiveValue:) method on the publisher the eraseToAnyPublisher() method returns. We weakly capture self, the view model, and update the locationCellViewModels property in the value handler.

// MARK: - Public API

func start() {
    let weatherService = self.weatherService

    store.locationsPublisher
        .map { locations in
            locations.map { location in
                LocationCellViewModel(
                    location: location,
                    weatherService: weatherService
                )
            }
        }
        .eraseToAnyPublisher()
        .sink { [weak self] locationCellViewModels in
            self?.locationCellViewModels = locationCellViewModels
        }
}

We need to hold on to the AnyCancellable object the sink(receiveValue:) method returns. Declare a private, variable property, subscriptions, of type Set<AnyCancellable> and set its initial value to an empty set.

private var subscriptions: Set<AnyCancellable> = []

Revisit the start() method. We invoke the store(in:) method to add the AnyCancellable object the sink(receiveValue:) method returns to the set of subscriptions.

// MARK: - Public API

func start() {
    let weatherService = self.weatherService

    store.locationsPublisher
        .map { locations in
            locations.map { location in
                LocationCellViewModel(
                    location: location,
                    weatherService: weatherService
                )
            }
        }
        .eraseToAnyPublisher()
        .sink { [weak self] locationCellViewModels in
            self?.locationCellViewModels = locationCellViewModels
        }.store(in: &subscriptions)
}

Open LocationsView.swift and remove the ObservedObject property wrapper from the viewModel declaration.

import SwiftUI

struct LocationsView: View {

    // MARK: - Properties

    private(set) var viewModel: LocationsViewModel

	...

}

Migrating AddLocationViewModel

Open AddLocationViewModel.swift. Migrating the AddLocationViewModel class requires more work because its implementation relies heavily on the Published property wrapper. In the setupBindings() method, the view model subscribes to the query and locations publishers.

Migrating the AddLocationViewModel class isn't worth the effort in my view. Why is that? The Observable macro is a welcome improvement. It makes it trivial to adopt Observation, but it doesn't replace the ObservableObject protocol, nor does it make the Published property wrapper obsolete.

The implementation of the AddLocationViewModel class is fine as is and doesn't require changes. We leave it as is for now.

What's Next?

Like property wrappers, macros are powerful and can simplify your code. They also have a flip side as you learned in this episode. Errors can be confusing and debugging issues related to macros can be problematic.