The LocationView is always in one of two states, fetching weather data or displaying weather data. It is the LocationView itself that implicitly defines these states and that is limiting. Not only is it limiting, we cannot write unit tests for the states of the LocationView. In this episode, I show you a solution that is testable and extensible.

Defining States

Open LocationView.swift and navigate to the computed body property. The if-else statement implicitly defines the states of the LocationView. If the view model has a view model for the top and bottom sections of the LocationView, then it displays weather data. If no view models are available, then the LocationView displays a ProgressView.

var body: some View {
    VStack(alignment: .leading, spacing: 0.0) {
        if
            let currentConditionsViewModel = viewModel.currentConditionsViewModel,
            let forecastViewModel = viewModel.forecastViewModel
        {
            CurrentConditionsView(
                viewModel: currentConditionsViewModel
            )

            Divider()

            ForecastView(
                viewModel: forecastViewModel
            )
        } else {
            ProgressView()
        }
    }
    .navigationTitle(viewModel.locationName)
    .task {
        await viewModel.start()
    }
}

If fetching weather data from the Clear Sky API fails for some reason, the application continues to display a ProgressView. The view model prints the error to the console, but that doesn't help the user.

The LocationViewModel class could define another Published property that exposes an error message to its view, but that would result in a complex if-else statement and moving more business logic to the view. That isn't in line with the Model-View-ViewModel pattern. Let's take a look at a simple, elegant, extensible solution you can apply to any user interface.

Open LocationViewModel.swift and define an enum with name State. As the name suggests, the State enum defines the states of the LocationView. Add two cases, fetching and data. The data case defines two associated values, currentConditionsViewModel of type CurrentConditionsViewModel and forecastViewModel of type ForecastViewModel. The names of the associated values make the case declaration pretty long, but I find that an acceptable trade-off to improve the readability at the call site.

// MARK: - Types

enum State {

    // MARK: - Cases

    case fetching
    case data(
        currentConditionsViewModel: CurrentConditionsViewModel,
        forecastViewModel: ForecastViewModel
    )

}

Let's put the State enum to use. Remove the currentConditionsViewModel and forecastViewModel properties. We no longer need them. Declare a Published, variable property with name state of type State. We declare the property's setter privately and set its initial value to fetching.

@Published private(set) var state: State = .fetching

In the start() method, we use the WeatherData object to create the view models for the top and bottom sections of the LocationView. We use the view models to create a State object and update the state property with it.

// MARK: - Public API

func start() async {
    do {
        let data = try await weatherService.weather(for: location)

        state = .data(
            currentConditionsViewModel: .init(currently: data.currently),
            forecastViewModel: .init(forecast: data.forecast)
        )
    } catch {
        print("Unable to Fetch Weather Data \(error)")
    }
}

Open LocationView.swift and revisit the computed body property. In the closure we pass to the VStack, we replace the if-else statement with a switch statement that switches on the view model's state property. This immediately improves the implementation because the switch statement is explicit about the possible states of the LocationView.

If the value of the state property is equal to fetching, the VStack contains a ProgressView.

var body: some View {
    VStack(alignment: .leading, spacing: 0.0) {
        switch viewModel.state {
        case .fetching:
            ProgressView()
        }
    }
    .navigationTitle(viewModel.locationName)
    .task {
        await viewModel.start()
    }
}

If the value of the state property is equal to data, the VStack contains a CurrentConditionsView and a ForecastView, separated by a Divider. The view models for the views are provides by the associated values of the data case.

var body: some View {
    VStack(alignment: .leading, spacing: 0.0) {
        switch viewModel.state {
        case .fetching:
            ProgressView()
        case let .data(currentConditionsViewModel: currentConditionsViewModel, forecastViewModel: forecastViewModel):
            CurrentConditionsView(
                viewModel: currentConditionsViewModel
            )

            Divider()

            ForecastView(
                viewModel: forecastViewModel
            )
        }
    }
    .navigationTitle(viewModel.locationName)
    .task {
        await viewModel.start()
    }
}

Handling Errors

With the State enum in place, it is trivial to add support for more states. Let's display an error message if the fetching of weather data fails. Revisit LocationViewModel.swift and add a case with name error. The error case defines an associated value with name message of type String.

// MARK: - Types

enum State {

    // MARK: - Cases

    case fetching
    case data(
        currentConditionsViewModel: CurrentConditionsViewModel,
        forecastViewModel: ForecastViewModel
    )
    case error(message: String)

}

Revisit the start() method of the LocationViewModel class. In the catch clause, we create a State object and update the state property with it. The associated value is the message the LocationView displays.

// MARK: - Public API

func start() async {
    do {
        let data = try await weatherService.weather(for: location)

        state = .data(
            currentConditionsViewModel: .init(currently: data.currently),
            forecastViewModel: .init(forecast: data.forecast)
        )
    } catch {
        print("Unable to Fetch Weather Data \(error)")
        state = .error(message: "Thunderstorm isn't able to display weather data for \(locationName). Please try again later.")
    }
}

Open LocationView.swift and revisit the computed body property of the LocationView. We add the error case to the switch statement and use the associated value, a string, to create a Text view. We apply padding, set the foreground color of the Text view to the application's accent color, and center the contents of the Text view.

var body: some View {
    VStack(alignment: .leading, spacing: 0.0) {
        switch viewModel.state {
        case .fetching:
            ProgressView()
        case let .data(currentConditionsViewModel: currentConditionsViewModel, forecastViewModel: forecastViewModel):
            CurrentConditionsView(
                viewModel: currentConditionsViewModel
            )

            Divider()

            ForecastView(
                viewModel: forecastViewModel
            )
        case .error(message: let message):
            Text(message)
                .padding()
                .foregroundColor(.accentColor)
                .multilineTextAlignment(.center)
        }
    }
    .navigationTitle(viewModel.locationName)
    .task {
        await viewModel.start()
    }
}

Previewing the Error State

We could verify the implementation of the error state by running the application and enabling airplane mode, but that would be tedious. Let's use a preview instead. The WeatherPreviewClient struct we implemented earlier in this series makes this straightforward.

Open WeatherPreviewClient.swift and declare a nested struct with name WeatherDataError that conforms to the Error protocol. We leave the implementation empty.

import Foundation

struct WeatherPreviewClient: WeatherService {

    // MARK: - Types

    struct WeatherDataError: Error {}

	...

}

Declare a private, constant property with name result of type Result. The Success type is WeatherData and the Failure type is WeatherDataError.

import Foundation

struct WeatherPreviewClient: WeatherService {

    // MARK: - Types

    struct WeatherDataError: Error {}

    // MARK: - Properties

    private let result: Result<WeatherData, WeatherDataError>

	...

}

Declare an initializer that accepts a Result object as its only argument. The type of the result parameter is identical to that of the result property. In the body of the initializer, we assign the value of the result parameter to the result property. To avoid breaking the previews that rely on the WeatherPreviewClient struct, we define a default value for the result parameter.

import Foundation

struct WeatherPreviewClient: WeatherService {

    // MARK: - Types

    struct WeatherDataError: Error {}

    // MARK: - Properties

    private let result: Result<WeatherData, WeatherDataError>

    // MARK: - Initialization

    init(result: Result<WeatherData, WeatherDataError> = .success(.preview)) {
        self.result = result
    }

	...

}

In the weather(for:) method, we switch on the value of the result property. If the value is equal to success, the weather(for:) method returns a WeatherData object. If the value is equal to failure, the weather(for:) method throws a WeatherDataError object.

import Foundation

struct WeatherPreviewClient: WeatherService {

    // MARK: - Types

    struct WeatherDataError: Error {}

    // MARK: - Properties

    private let result: Result<WeatherData, WeatherDataError>

    // MARK: - Initialization

    init(result: Result<WeatherData, WeatherDataError> = .success(.preview)) {
        self.result = result
    }

    // MARK: - Weather Service

    func weather(for location: Location) async throws -> WeatherData {
        switch result {
        case .success(let weatherData):
            return weatherData
        case .failure(let error):
            throw error
        }
    }

}

Revisit LocationView.swift and navigate to the LocationView_Previews struct. In the static previews property, we duplicate the NavigationView and wrap both views in a Group. To display the error state, we configure the WeatherPreviewClient object to throw an error if its weather(for:) method is invoked. We can now quickly and easily switch between the data and error states of the LocationView.

struct LocationView_Previews: PreviewProvider {
    static var previews: some View {
        Group {
            NavigationView {
                LocationView(
                    viewModel: .init(
                        location: .preview,
                        weatherService: WeatherPreviewClient()
                    )
                )
            }
            NavigationView {
                LocationView(
                    viewModel: .init(
                        location: .preview,
                        weatherService: WeatherPreviewClient(result: .failure(.init()))
                    )
                )
            }
        }
    }
}

What's Next?

Views often need to support multiple states and the solution we implemented in this episode makes this painless. Not only is it easy to extend the State enum, we can unit test the implementation. That isn't possible if we put the view in charge of state management.