In this and the next episode, we shift focus to the week view controller. Populating the week view requires a different approach. It contains a table view and the week view model manages an array of WeahterDayData objects. Even though the approach is different, the patterns we apply are similar.

Creating a Base Class

The day view model and the week view model have quite a bit in common. To avoid code duplication, we create a base class DayViewModel and WeekViewModel inherit from. Create a Swift file in the View Models group and name it WeatherViewModel.swift. Add an import statement for the Combine framework and declare a class with name WeatherViewModel.

import Combine
import Foundation

class WeatherViewModel {

}

Open DayViewModel.swift in the Assistant Editor on the right and move loadingPublisher, hasWeatherDataPublisher, hasWeatherDataErrorPublisher, and weatherDataStatePublisher from DayViewModel to WeatherViewModel.

import Combine
import Foundation

class WeatherViewModel {

    // 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>

}

Implement an initializer that accepts a publisher of type AnyPubliser<WeatherDataState, Never>. The Output and Failure type of the publisher match those of the weatherDataStatePublisher property.

import Combine
import Foundation

class WeatherViewModel {

    // 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: - Initialization

    init(weatherDataStatePublisher: AnyPublisher<WeatherDataState, Never>) {
        // Set Properties
        self.weatherDataStatePublisher = weatherDataStatePublisher
    }

}

Open DayViewModel.swift and convert DayViewModel to a class that inherits from WeatherViewModel. We apply the final keyword to the class declaration since DayViewModel isn't intended to be subclassed.

import UIKit
import Combine

final class DayViewModel: WeatherViewModel {

    ...

}

Refactoring the Week View Model

Open WeekViewModel.swift and convert WeekViewModel to a class that inherits from WeatherViewModel. We apply the final keyword to the class declaration. Like DayViewModel, WeekViewModel isn't intended to be subclassed.

import UIKit

final class WeekViewModel: WeatherViewModel {

    ...

}

To keep the compiler happy, we convert the weatherData property to a variable property and set its initial value to an empty array. This is a temporary solution.

import UIKit

final class WeekViewModel: WeatherViewModel {

    // MARK: - Properties

    var weatherData: [WeatherDayData] = []

    ...

}

Refactoring the Weather View Controller

Before we refactor the WeekViewController class, we need to make some changes to its superclass, WeatherViewController. It is time to reap the benefits of the changes we implemented earlier. Open WeatherViewController.swift and add an import statement for the Combine framework at the top.

import UIKit
import Combine

class WeatherViewController: UIViewController {

    ...

}

Declare a computed property, weatherViewModel, of type WeatherViewModel. In the body of the computed property, we throw a fatal error. WeatherViewController subclasses are required to override this computed property. By throwing a fatal error, Xcode alerts us that we forgot to meet this requirement.

import UIKit
import Combine

class WeatherViewController: UIViewController {

    // MARK: - Properties

    @IBOutlet private(set) var messageLabel: UILabel!
    @IBOutlet private(set) var weatherDataContainerView: UIView!
    @IBOutlet private(set) var activityIndicatorView: UIActivityIndicatorView!

    // MARK: -

    var weatherViewModel: WeatherViewModel {
        fatalError("Subclasses are required to override `weatherViewModel`.")
    }

    ...

}

Open DayViewController.swift and override the weatherViewModel computed property. We use a guard statement to safely unwrap viewModel and throw a fatal error if viewModel is equal to nil.

override var weatherViewModel: WeatherViewModel {
    guard let viewModel = viewModel else {
        fatalError("No View Model Available")
    }

    return viewModel
}

You could argue that forced unwrapping the viewModel property is more concise and that the result is identical. I don't agree, though. I use the exclamation mark very, very sparingly and use fatal errors if I want to communicate that a certain code path should never be executed. It is easy to miss an exclamation mark and once you start using it, it is difficult to make a strong case for not using it. That is a personal preference, though.

We could avoid this problem altogether by implementing a custom initializer for the weather view controllers, but I leave that as an exercise for you.

Copy the implementation of the weatherViewModel computed property of the DayViewController class to the WeekViewController class.

Revisit WeatherViewController.swift. In viewDidLoad(), we invoke a helper method, bind(to:). The WeatherViewModel instance is passed in as an argument.

// MARK: - View Life Cycle

override func viewDidLoad() {
    super.viewDidLoad()

    // Setup View
    setupView()

    // Bind to View Model
    bind(to: weatherViewModel)
}

Before we move on, we declare a private, variable property, subscriptions, of type Set<AnyCancellable> and set its initial value to an empty set. Because we declare the subscriptions property privately, the declaration doesn't conflict with the subscriptions property of the DayViewController class. We declare it privately to avoid other objects from accessing it.

private var subscriptions: Set<AnyCancellable> = []

Open DayViewController.swift in the Assistant Editor on the right. We keep the bind(to:) method private to the WeatherViewController class. We move the subscriptions to the view model's loadingPublisher, hasWeatherDataPublisher, and hasWeatherDataErrorPublisher from the day view controller's setupBindings() method to the weather view controller's bind(to:) method.

// MARK: - Helper Methods

private func bind(to viewModel: WeatherViewModel) {
    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)
}

Before we move on, we remove the reloadData() method. Remember that we no longer need this method to update the user interface of the weather view controllers. The Combine framework takes care of that.

Refactoring the Week View Controller

Let's shift focus to the WeekViewController class. Open WeekViewController.swift and add an import statement for the Combine framework at the top.

import UIKit
import Combine

protocol WeekViewControllerDelegate: AnyObject {
    func controllerDidRefresh(controller: WeekViewController)
}

final class WeekViewController: WeatherViewController {

    ...

}

Before we reactify the WeekViewController class, we remove what we no longer need. We start with the viewModel property. The didSet property observer is no longer needed. Remember that a didSet property observer is often a code smell in a project that embraces reactive programming.

var viewModel: WeekViewModel?

The week view controller configures its message label in the setupView() method. The updateView() method is no longer needed.

// MARK: - View Methods

private func setupView() {
    setupRefreshControl()

    // Configure Message Label
    messageLabel.text = "Cloudy was unable to fetch weather data."
}

We can also remove the reloadData() and updateWeatherDataContainerView() methods because the user interface of the WeekViewController class is automatically updated when the view model's publishers emit data.

In viewDidLoad(), we invoke setupBindings(), a helper method. We could move this method to the WeatherViewController class and have each subclass override it. That isn't strictly necessary, though. The drawback is that we wouldn't be able to declare it privately.

// MARK: - View Life Cycle

override func viewDidLoad() {
    super.viewDidLoad()

    // Setup Bindings
    setupBindings()

    // Setup View
    setupView()
}

We implement setupBindings() in the next episode.

// MARK: - Helper Methods

private func setupBindings() {

}

Refactoring the Root View Controller

Open RootViewController.swift and navigate to the prepare(for:sender:) method. The root view controller creates a WeekViewModel instance and assigns it to the week view controller's viewModel property.

// MARK: - Navigation

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
    guard let viewModel = viewModel, let identifier = segue.identifier else {
        return
    }

    switch identifier {
    ...
    case Segue.weekView:
        guard let destination = segue.destination as? WeekViewController else {
            fatalError("Unexpected Destination View Controller")
        }

        // Configure Destination
        destination.delegate = self

        // Update Week View Controller
        self.weekViewController = destination
        self.weekViewController.viewModel = WeekViewModel(weatherDataStatePublisher: viewModel.weatherDataStatePublisher)
    ...
    }
}

We can simplify the setupBindings() method. The root view controller should only be notified when its view model encounters an error. The compactMap operator creates a publisher that emits WeatherDataError objects. The root view controller subscribes to the resulting publisher by invoking the sink(receiveValue:) method. It uses a switch statement to figure out which type of alert to present to the user.

// MARK: - Helper Methods

private func setupBindings() {
    viewModel?.weatherDataStatePublisher
        .compactMap { $0.weatherDataError }
        .sink { [weak self] error in
            switch error {
            case .notAuthorizedToRequestLocation:
                self?.presentAlert(of: .notAuthorizedToRequestLocation)
            case .failedToRequestLocation:
                self?.presentAlert(of: .failedToRequestLocation)
            case .failedRequest,
                 .invalidResponse:
                self?.presentAlert(of: .noWeatherDataAvailable)
            }
        }.store(in: &subscriptions)
}

Before we build and run the application, we remove the references to the reloadData() method of the WeatherViewController class. This means the weather view controllers no longer respond when the application's settings change. We resolve that later in this series.

extension RootViewController: SettingsViewControllerDelegate {

    func controllerDidChangeTimeNotation(controller: SettingsViewController) {

    }

    func controllerDidChangeUnitsNotation(controller: SettingsViewController) {

    }

    func controllerDidChangeTemperatureNotation(controller: SettingsViewController) {

    }

}

Build and run the application. The week view doesn't display any weather data, which isn't surprising. The view model's weatherData property stores an empty array. In the next episode, we resolve this problem by combining diffable data sources and the Combine framework. The result is quite amazing.

What's Next?

In this episode, we reaped the benefits of the foundation we laid earlier in this series. The WeatherDataState enum makes it almost trivial to populate the weather view controllers.

Value types are incredibly useful, but this episode has illustrated that classes and inheritance have their place. We were able to reduce code duplication by creating a base class from which DayViewModel and WeekViewModel inherit.