In this episode, we fix the settings view using the Combine framework. We no longer rely on the delegation pattern to propagate settings changes. In the previous episode, we created a view model for the settings view controller. The settings view model exposes a publisher for each setting. Objects interested in settings changes can subscribe to these publishers. Let me show you how that works.

Injecting Publishers

The idea is simple. The root view controller is responsible for creating the view models of the day and week view controllers. The plan is to inject the publishers of the settings view model into those view models. This means the root view controller needs to keep a reference to the view model of the settings view controller.

Open RootViewController.swift and declare a private, constant property with name settingsViewModel. The root view controller creates a SettingsViewModel instance and assigns it to its settingsViewModel property.

private let settingsViewModel = SettingsViewModel()

Navigate to the segue action we implemented in the previous episode. The root view controller doesn't need to create a SettingsViewModel instance every time the segue action is executed. Instead, we pass a reference to the settings view model of the root view controller to the initializer of the SettingsViewController class.

// MARK: - Segue Actions

@IBSegueAction private func showSettings(coder: NSCoder) -> SettingsViewController? {
    SettingsViewController(coder: coder, viewModel: settingsViewModel)
}

As I mentioned earlier, the plan is to inject the publishers of the settings view model into the weather view models. Open WeatherViewModel.swift and define a property for each setting, timeNotationPublisher, unitsNotationPublisher, and temperatureNotationPublisher. The types of the properties match those of the SettingsViewModel class.

let timeNotationPublisher: AnyPublisher<TimeNotation, Never>
let unitsNotationPublisher: AnyPublisher<UnitsNotation, Never>
let temperatureNotationPublisher: AnyPublisher<TemperatureNotation, Never>

The initializer of the WeatherViewModel class accepts the publishers as arguments. The properties we declared a moment ago hold a reference to the publishers.

// MARK: - Initialization

init(weatherDataStatePublisher: AnyPublisher<WeatherDataState, Never>,
     timeNotationPublisher: AnyPublisher<TimeNotation, Never>,
     unitsNotationPublisher: AnyPublisher<UnitsNotation, Never>,
     temperatureNotationPublisher: AnyPublisher<TemperatureNotation, Never>) {
    // Set Properties
    self.weatherDataStatePublisher = weatherDataStatePublisher
    self.timeNotationPublisher = timeNotationPublisher
    self.unitsNotationPublisher = unitsNotationPublisher
    self.temperatureNotationPublisher = temperatureNotationPublisher
}

Open RootViewController.swift, navigate to the prepare(for:sender:) method, and update the initialization of the weather view models. The publishers of the settings view model are injected into the weather view models using initializer injection.

// MARK: - Navigation

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

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

        // Configure Destination
        destination.delegate = self

        // Update Day View Controller
        self.dayViewController = destination
        self.dayViewController.viewModel = DayViewModel(weatherDataStatePublisher: viewModel.weatherDataStatePublisher,
                                                        timeNotationPublisher: settingsViewModel.timeNotationPublisher,
                                                        unitsNotationPublisher: settingsViewModel.unitsNotationPublisher,
                                                        temperatureNotationPublisher: settingsViewModel.temperatureNotationPublisher)
    case Segue.weekView:
        guard let destination = segue.destination as? WeekViewController else {
            fatalError("Unexpected Destination View Controller")
        }

        // Update Week View Controller
        self.weekViewController = destination
        self.weekViewController.viewModel = WeekViewModel(weatherDataStatePublisher: viewModel.weatherDataStatePublisher,
                                                          timeNotationPublisher: settingsViewModel.timeNotationPublisher,
                                                          unitsNotationPublisher: settingsViewModel.unitsNotationPublisher,
                                                          temperatureNotationPublisher: settingsViewModel.temperatureNotationPublisher)
    case Segue.locationsView:
        ...
    default:
        break
    }
}

Combining Publishers in the Day View

The next step should be familiar. We combine the settings publishers with the weather data publisher of the weather data view model. Let's start with the DayViewModel class. We remove the references to the UserDefaults class using the settings publishers. Three properties need to be updated, timePublisher, temperaturePublisher, and windSpeedPublisher.

We start with the timePublisher property. We no longer ask the user defaults database for the date format. Instead, we use the combineLatest operator to combine the output of weatherDataPublisher and timeNotationPublisher. The resulting publisher emits a tuple with two values, the weather data emitted by weatherDataPublisher and the time notation setting emitted by timeNotationPublisher.

We apply the map operator to the publisher the combineLatest operator creates. The closure of the map operator accepts the weather data and the time notation setting as arguments. The return type of the closure is String?.

In the closure, we set the date formatter's dateFormat property to the value of the dateFormat property of the TimeNotation object. The timestamp of the weather data is formatted by passing it to the string(from:) method of the date formatter. The eraseToAnyPublisher() method wraps the resulting publisher with a type eraser.

var timePublisher: AnyPublisher<String?, Never> {
    // Initialize Date Formatter
    let dateFormatter = DateFormatter()

    return weatherDataPublisher.combineLatest(timeNotationPublisher)
        .map { weatherData, timeNotation -> String? in
            dateFormatter.dateFormat = timeNotation.dateFormat
            return dateFormatter.string(from: weatherData.time)
        }
        .eraseToAnyPublisher()
}

The result is exactly what we want. Every time the time notation setting changes, timePublisher emits a new value and the same is true for weatherDataPublisher. Every time the weather data changes, timePublisher emits a new value. The combineLatest operator is a perfect fit.

The changes we need to apply to temperaturePublisher are similar. We use the combineLatest operator to combine the output of weatherDataPublisher and temperatureNotationPublisher. The resulting publisher emits a tuple with two values, the weather data emitted by weatherDataPublisher and the temperature notation setting emitted by temperatureNotationPublisher.

We apply the map operator to the publisher the combineLatest operator creates. The closure of the map operator accepts the weather data and the temperature notation setting as arguments. The return type of the closure is String?.

The closure no longer depends on the value stored in the user defaults database. Instead, we switch on the temperature notation setting emitted by temperatureNotationPublisher. That's it.

var temperaturePublisher: AnyPublisher<String?, Never> {
    weatherDataPublisher.combineLatest(temperatureNotationPublisher)
        .map { weatherData, temperatureNotation -> String? in
            switch temperatureNotation {
            case .fahrenheit:
                return String(format: "%.1f °F", weatherData.temperature)
            case .celsius:
                return String(format: "%.1f °C", weatherData.temperature.toCelcius)
            }
        }
        .eraseToAnyPublisher()
}

The changes we need to apply to windSpeedPublisher are almost identical to those of temperaturePublisher. The difference is that we combine weatherDataPublisher with unitsNotationPublisher and switch on the units notation setting the publisher emits.

var windSpeedPublisher: AnyPublisher<String?, Never> {
    weatherDataPublisher.combineLatest(unitsNotationPublisher)
        .map { weatherData, unitsNotation -> String? in
            switch unitsNotation {
            case .imperial:
                return String(format: "%.f MPH", weatherData.windSpeed)
            case .metric:
                return String(format: "%.f KPH", weatherData.windSpeed.toKPH)
            }
        }
        .eraseToAnyPublisher()
}

Build and run the application to see the result. Open the settings view and change one of the settings. Dismiss the settings view and inspect the values displayed by the day view controller at the top.

Combining Publishers in the Week View

Updating the week view every time a setting changes is similar, but there are a few differences since we are working with a table view. We start by updating the WeatherDayViewModel struct. The WeatherDayViewModel struct relies on the user defaults database to format its weather data. That needs to change.

Declare two properties, unitsNotation of type UnitsNotation and temperatureNotation of type TemperatureNotation. By injecting the settings into the view model, unit testing becomes much easier. That is a nice bonus.

import UIKit

struct WeatherDayViewModel: WeatherDayPresentable {

    // MARK: - Properties

    let weatherDayData: WeatherDayData

    // MARK: -

    let unitsNotation: UnitsNotation
    let temperatureNotation: TemperatureNotation

    ...

}

In the closure of the windSpeed computed property, we switch on the value of the unitsNotation property. We no longer ask the user default database for the units notation setting.

var windSpeed: String {
    let windSpeed = weatherDayData.windSpeed

    switch unitsNotation {
    case .imperial:
        return String(format: "%.f MPH", windSpeed)
    case .metric:
        return String(format: "%.f KPH", windSpeed.toKPH)
    }
}

We apply a similar change to the format(temperature:) method. The view model switches on the value of the temperatureNotation property. It no longer depends on the temperature notation setting stored in the user defaults database.

// MARK: - Helper Methods

private func format(temperature: Double) -> String {
    switch temperatureNotation {
    case .fahrenheit:
        return String(format: "%.0f °F", temperature)
    case .celsius:
        return String(format: "%.0f °C", temperature.toCelcius)
    }
}

With the changes of the WeatherDayViewModel struct in place, it is time to focus on the WeekViewModel. Open WeekViewModel.swift and change the item identifier type of the diffable data source from WeatherData to WeatherDayViewModel.

import UIKit
import Combine

final class WeekViewModel: WeatherViewModel {

    // MARK: - Properties

    private var dataSource: UITableViewDiffableDataSource<Int, WeatherDayViewModel>?

    ...

}

The compiler throws an error because WeatherDayViewModel doesn't conform to the Hashable protocol, which is a requirement for the item identifier type of a diffable data source. We covered this earlier in this series when we discussed diffable data sources.

The item identifier type doesn't conform to the Hashable protocol.

Revisit WeatherDayViewModel.swift and conform the WeatherDayViewModel struct to the Hashable protocol. The compiler takes care of the rest.

import UIKit

struct WeatherDayViewModel: WeatherDayPresentable, Hashable {

    ...

}

Revisit WeekViewModel.swift. We apply the same technique we applied to the DayViewModel class. The difference is that we combine three publishers instead of two. Before we combine any publishers, we store the result of the compactMap operator in a constant with name weatherDailyDataPublisher.

// MARK: - Public API

func start() {
    let weatherDailyDataPublisher = weatherDataStatePublisher
        .compactMap { $0.weatherData?.dailyData }

    weatherDataStatePublisher
        .compactMap { $0.weatherData?.dailyData }
        .sink { [weak self] weatherData in
            // Create Diffable Data Source Snapshot
            var snapshot = NSDiffableDataSourceSnapshot<Int, WeatherDayData>()

            // Append Sections
            snapshot.appendSections([0])

            // Append Items
            snapshot.appendItems(weatherData)

            // Apply Snapshot
            self?.dataSource?.apply(snapshot, animatingDifferences: false)
        }
        .store(in: &subscriptions)
}

We then apply the combineLatest operator to weatherDailyDataPublisher. The publishers we pass to the combineLatest(_:) method are unitsNotationPublisher and temperatureNotationPublisher. The resulting publisher emits a tuple with three values, the weather data emitted by weatherDailyDataPublisher, the units notation setting emitted by unitsNotationPublisher, and the temperature notation setting emitted by temperatureNotationPublisher.

We apply a map operator to the resulting publisher. The closure that is passed to the map operator accepts the tuple as an argument and returns an array of WeatherDayViewModel objects. In the closure, we map the array of weather data to an array of WeatherDayViewModel objects.

// MARK: - Public API

func start() {
    let weatherDailyDataPublisher = weatherDataStatePublisher
        .compactMap { $0.weatherData?.dailyData }

    weatherDailyDataPublisher
        .combineLatest(unitsNotationPublisher, temperatureNotationPublisher)
        .map { dailyData, unitsNotation, temperatureNotation -> [WeatherDayViewModel] in
            dailyData.map { WeatherDayViewModel(weatherDayData: $0,
                                                unitsNotation: unitsNotation,
                                                temperatureNotation: temperatureNotation) }
        }
        .sink { [weak self] weatherData in
            // Create Diffable Data Source Snapshot
            var snapshot = NSDiffableDataSourceSnapshot<Int, WeatherDayData>()

            // Append Sections
            snapshot.appendSections([0])

            // Append Items
            snapshot.appendItems(weatherData)

            // Apply Snapshot
            self?.dataSource?.apply(snapshot, animatingDifferences: false)
        }
        .store(in: &subscriptions)
}

In the closure that is passed to the sink(_:) method, we change the item identifier type of the snapshot.

// Create Diffable Data Source Snapshot
var snapshot = NSDiffableDataSourceSnapshot<Int, WeatherDayViewModel>()

Before we build and run the application, we need to make two changes to the bind(tableView:) method. Remember that the initializer of the UITableViewDiffableDataSource class accepts a cell provider as its second argument. A cell provider is a closure that accepts three arguments. The last argument is an item identifier. In this example, the item identifier is a WeatherDayViewModel object. Change the name of the argument to presentable. That makes more sense.

The configure(with:) method of the WeatherDayTableViewCell class accepts a WeatherDayPresentable object as its only argument. Because WeatherDayViewModel conforms to the WeatherDayPresentable protocol, we can pass it directly to the cell's configure(with:) method.

func bind(tableView: UITableView) {
    // Create Table View Diffable Data Source
    dataSource = UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, presentable -> UITableViewCell? in
        guard let cell = tableView.dequeueReusableCell(withIdentifier: WeatherDayTableViewCell.reuseIdentifier, for: indexPath) as? WeatherDayTableViewCell else {
            fatalError("Unable to Dequeue Weather Day Table View Cell")
        }

        cell.configure(with: presentable)

        return cell
    }
}

Build and run the application, open the settings view, and change one of the settings. Dismiss the settings view and verify that the day and week view controllers correctly display the weather data to the user.

Keeping It Private

The settings view model is the only object that needs access to the application settings stored in the user defaults database. This means we can move the class properties for the application settings from UserDefaults.swift to SettingsViewModel.swift.

You may be wondering why that is helpful? The benefit is that we can declare the class computed properties as fileprivate. This subtle change makes it easier to prevent other objects from accessing the application settings stored in the user defaults database. The SettingsViewModel class monitors access to the application settings.

What's Next?

We have drastically improved the management of the application's settings using Combine and the combineLatest operator. The settings view model is the only object that interacts with the application settings stored in the user defaults database. This is a significant improvement that also facilitates unit testing.