We ran into several issues in the previous episode. The assign(to:on) and sink(receiveValue:) methods are convenient, but they don't always cut it. We need a solution that is robust and scales with the complexity of the project.
Defining State
In the previous episode, I showed you that three variables define the state of the user interface of the day view controller. It needs to support three states, loading, displaying weather data, and displaying an error message.
In this episode, we encapsulate these three states in a type. Because the three states are mutually exclusive, an enum is the perfect choice. Add a Swift file to the Types group, name it WeatherDataState.swift, and define an enum with name WeatherDataState. The WeatherDataState enum defines three cases, loading, data, and error. The data case has an associated value of type WeatherData and the error case has an associated value of type WeatherDataError.
import Foundation
enum WeatherDataState {
// MARK: - Cases
case loading
case data(WeatherData)
case error(WeatherDataError)
}
With the WeatherDataState enum in place, we can put it to use in the RootViewModel class. Open RootViewModel.swift and declare a private, constant property, weatherDataStateSubject of type PassthroughSubject<WeatherDataState, Never>. The Output type of the subject is WeatherDataState and the Failure type of the subject is Never.
private let weatherDataStateSubject = PassthroughSubject<WeatherDataState, Never>()
We define a computed property to expose a publisher for the subject. The computed property, weatherDataStatePublisher, is of type AnyPublisher and has an Output type of WeatherDataState and a Failure type of Never. We invoke the eraseToAnyPublisher() method to hide or erase the type of weatherDataStateSubject.
var weatherDataStatePublisher: AnyPublisher<WeatherDataState, Never> {
weatherDataStateSubject
.eraseToAnyPublisher()
}
private let weatherDataStateSubject = PassthroughSubject<WeatherDataState, Never>()
Loading State
The weatherDataStateSubject property makes the loadingSubject property obsolete. We can remove the loadingSubject and loadingPublisher properties.
Navigate to the fetchWeatherData(for:) method of the RootViewModel class. The root view model can communicate to other objects when it is fetching weather data by sending values through weatherDataStateSubject. When it resumes the weather data task in its fetchWeatherData(for:) method, it calls send(_:) on weatherDataStateSubject, passing in loading. The send(_:) method injects a value into the stream of values weatherDataStateSubject publishes. We covered this earlier in this series. We also remove the reference to loadingSubject in the completion handler of the data task.
private func fetchWeatherData(for location: CLLocation) {
// Cancel In Progress Data Task
weatherDataTask?.cancel()
// Helpers
let latitude = location.coordinate.latitude
let longitude = location.coordinate.longitude
// Create URL
let url = WeatherServiceRequest(latitude: latitude, longitude: longitude).url
// Create Data Task
weatherDataTask = URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
DispatchQueue.main.async {
self?.didFetchWeatherData(data: data, response: response, error: error)
}
}
// Start Data Task
weatherDataTask?.resume()
weatherDataStateSubject.send(.loading)
}
Data and Error States
The weatherDataStateSubject property replaces the weatherData and weatherDataError properties. We remove both properties as well as weatherDataPublisher and weatherDataErrorPublisher.
We also need to update the didFetchWeatherData(data:response:error:) method. If the root view model successfully fetches weather data from the weather service, it publishes the weather data through weatherDataStateSubject by invoking its send(_:) method.
// Create JSON Decoder
let decoder = JSONDecoder()
// Configure JSON Decoder
decoder.dateDecodingStrategy = .secondsSince1970
// Decode JSON
let weatherData = try decoder.decode(WeatherData.self, from: data)
// Update Weather Data State Subject
weatherDataStateSubject.send(.data(weatherData))
If the root view model isn't able to fetch weather data from the weather service, it publishes an error through weatherDataStateSubject by invoking its send(_:) method.
private func didFetchWeatherData(data: Data?, response: URLResponse?, error: Error?) {
if let error = error {
weatherDataStateSubject.send(.error(.failedRequest))
print("Unable to Fetch Weather Data, \(error)")
} else if let data = data, let response = response as? HTTPURLResponse {
if response.statusCode == 200 {
do {
// Create JSON Decoder
let decoder = JSONDecoder()
// Configure JSON Decoder
decoder.dateDecodingStrategy = .secondsSince1970
// Decode JSON
let weatherData = try decoder.decode(WeatherData.self, from: data)
// Update Weather Data State Subject
weatherDataStateSubject.send(.data(weatherData))
} catch {
weatherDataStateSubject.send(.error(.invalidResponse))
print("Unable to Decode Response, \(error)")
}
} else {
weatherDataStateSubject.send(.error(.failedRequest))
}
} else {
fatalError("Invalid Response")
}
}
The changes we made are subtle but have a significant impact. The RootViewModel class no longer holds on to the weather data it fetches from the weather service. It passes the weather data to other objects by publishing it through weatherDataStateSubject. It doesn't hold on to the weather data or a weather data error.
Updating the Root View Controller
Before we refactor the DayViewModel and DayViewController classes, we need to update the RootViewController class. Open RootViewController.swift and navigate to the setupBindings() method. The root view controller no longer subscribes to the weatherData and weatherDataError publishers. It subscribes to the view model's weatherDataStatePublisher instead. The changes we need to make are small. We restructure the code of the setupBindings() method, using a switch statement to access the weather data and weather data error the publisher emits.
private func setupBindings() {
viewModel?.weatherDataStatePublisher
.sink { [weak self] weatherDataState in
switch weatherDataState {
case .loading:
break
case .data(let weatherData):
// Configure Week View Controller
self?.weekViewController.viewModel = WeekViewModel(weatherData: weatherData.dailyData)
case .error(let error):
switch error {
case .notAuthorizedToRequestLocation:
self?.presentAlert(of: .notAuthorizedToRequestLocation)
case .failedToRequestLocation:
self?.presentAlert(of: .failedToRequestLocation)
case .failedRequest,
.invalidResponse:
self?.presentAlert(of: .noWeatherDataAvailable)
}
// Update Week View Controller
self?.weekViewController.viewModel = nil
}
}.store(in: &subscriptions)
}
We improve the implementation later in this series when we reactify the WeekViewController class.
Defining Convenience Computed Properties
Before we refactor the DayViewModel struct, we add three convenience computed properties to the WeatherDataState enum. Open WeatherDataState.swift and define three computed properties, isLoading of type Bool, weatherData of type WeatherData?, and weatherDataError of type WeatherDataError?.
import Foundation
enum WeatherDataState {
// MARK: - Cases
case loading
case data(WeatherData)
case error(WeatherDataError)
// MARK: - Properties
var isLoading: Bool {
switch self {
case .loading:
return true
case .data, .error:
return false
}
}
var weatherData: WeatherData? {
switch self {
case .data(let weatherData):
return weatherData
case .error,
.loading:
return nil
}
}
var weatherDataError: WeatherDataError? {
switch self {
case .error(let weatherDataError):
return weatherDataError
case .data,
.loading:
return nil
}
}
}
Refactoring the Day View Model
Open DayViewModel.swift and define a constant property, weatherDataStatePublisher, of type AnyPublisher. The Output type of the subject is WeatherDataState and the Failure type of the subject is Never.
import UIKit
import Combine
struct DayViewModel {
// MARK: - Properties
let loadingPublisher: AnyPublisher<Bool, Never>
let weatherDataPublisher: AnyPublisher<WeatherData, Never>
let weatherDataErrorPublisher: AnyPublisher<WeatherDataError, Never>
let weatherDataStatePublisher: AnyPublisher<WeatherDataState, Never>
...
}
We refactor the weatherDataPublisher property by converting it to a private computed property. In the body of the computed property, the day view model applies the compactMap operator to weatherDataStatePublisher. The closure that is passed to the compactMap operator returns the value of the weatherData computed property. The eraseToAnyPublisher() method is used to hide the type of the resulting publisher.
private var weatherDataPublisher: AnyPublisher<WeatherData, Never> {
weatherDataStatePublisher
.compactMap { $0.weatherData }
.eraseToAnyPublisher()
}
To keep the implementation of the DayViewController class simple and readable, we remove the weatherDataErrorPublisher property and declare two additional computed properties, hasWeatherDataPublisher, and hasWeatherDataErrorPublisher. We also turn loadingPublisher into a computed property.
import UIKit
import Combine
struct DayViewModel {
// MARK: - Properties
var loadingPublisher: AnyPublisher<Bool, Never>
var hasWeatherDataPublisher: AnyPublisher<Bool, Never>
var hasWeatherDataErrorPublisher: AnyPublisher<Bool, Never>
let weatherDataStatePublisher: AnyPublisher<WeatherDataState, Never>
// MARK: -
private var weatherDataPublisher: AnyPublisher<WeatherData, Never> {
weatherDataStatePublisher
.compactMap { $0.weatherData }
.eraseToAnyPublisher()
}
...
}
With the help of the map operator and the computed properties of the WeatherDataState enum, the implementations of the computed properties are short and readable.
import UIKit
import Combine
struct DayViewModel {
// MARK: - Properties
var loadingPublisher: AnyPublisher<Bool, Never> {
weatherDataStatePublisher
.map { $0.isLoading }
.eraseToAnyPublisher()
}
var hasWeatherDataPublisher: AnyPublisher<Bool, Never> {
weatherDataStatePublisher
.map { $0.weatherData != nil }
.eraseToAnyPublisher()
}
var hasWeatherDataErrorPublisher: AnyPublisher<Bool, Never> {
weatherDataStatePublisher
.map { $0.weatherDataError != nil }
.eraseToAnyPublisher()
}
// MARK: -
let weatherDataStatePublisher: AnyPublisher<WeatherDataState, Never>
// MARK: -
private var weatherDataPublisher: AnyPublisher<WeatherData, Never> {
weatherDataStatePublisher
.compactMap { $0.weatherData }
.eraseToAnyPublisher()
}
...
}
Refactoring the Day View Controller
Open DayViewController.swift and navigate to the setupBindings() method. We can drastically simplify the implementation with the help of the computed properties we added to the DayViewModel struct. Because the day view controller uses the assign(to:on:) method to update the isHidden properties of its views, we need to apply the map operator to invert the values the publishers emit. I am not a fan of the isHidden property and usually define a property with name isVisible to avoid this issue.
viewModel?.loadingPublisher
.map { !$0 }
.assign(to: \.isHidden, on: activityIndicatorView)
.store(in: &subscriptions)
viewModel?.hasWeatherDataPublisher
.map { !$0 }
.assign(to: \.isHidden, on: weatherDataContainerView)
.store(in: &subscriptions)
viewModel?.hasWeatherDataErrorPublisher
.map { !$0 }
.assign(to: \.isHidden, on: messageLabel)
.store(in: &subscriptions)
Build and Run
Before we build and run the application, we need to update the RootViewController class. Navigate to the prepare(for:sender:) method in RootViewController.swift and update the initialization of the DayViewModel object. The initializer of the DayViewModel struct has become much simpler, accepting a single argument. Build and run the application to see the result.
self.dayViewController.viewModel = DayViewModel(weatherDataStatePublisher: viewModel.weatherDataStatePublisher)
What's Next?
In this episode, we drastically improved the implementation of RootViewModel, DayViewModel, and DayViewController. We replaced the weatherData and weatherDataError properties of the RootViewModel class with a passthrough subject. The WeatherDataState subject encapsulates the state of the day view controller and we can reuse it for the week view controller, later in this series. The setupBindings() method of the DayViewController class has become shorter and easier to understand.