We made good progress in the previous episodes, but we need to make some changes to the RootViewController and RootViewModel classes. We address three issues in this episode. First, the RootViewController class shouldn't be aware of CLLocation objects. Second, the RootViewModel class should expose a publisher that emits weather data. Third, we need to restore the pull-to-refresh feature. Let's get started.

Refactoring the Root View Model

The RootViewController class should only be concerned with displaying the weather data its view model fetches. It shouldn't need to deal with CLLocation objects.

We currently use a pulling mechanism. The root view controller asks its view model for weather data. That isn't wrong, but there is a better, more reactive solution. The root view model should push weather data to the root view controller and the latter should simply display what it receives from its view model. This isn't difficult to implement.

Open RootViewModel.swift. We declare a variable property, weatherData, of type WeatherData? and apply the Published property wrapper to it. We use the private keyword to only expose the getter of the weatherData property.

import UIKit
import Combine
import CoreLocation

final class RootViewModel: NSObject {

    // MARK: - Type Aliases

    typealias WeatherDataResult = (Result<WeatherData, WeatherDataError>) -> ()

    // MARK: - Properties

    @Published private(set) var currentLocation: CLLocation?

    // MARK: -

    @Published private(set) var weatherData: WeatherData?

    ...

}

The root view controller should also be notified when its view model runs into an error. We declare another variable property, weatherDataError, of type WeatherDataError? and apply the Published property wrapper to it. The root view controller shouldn't modify the weatherDataError property so we use the private keyword to only expose the getter of the property.

import UIKit
import Combine
import CoreLocation

final class RootViewModel: NSObject {

    // MARK: - Type Aliases

    typealias WeatherDataResult = (Result<WeatherData, WeatherDataError>) -> ()

    // MARK: - Properties

    @Published private(set) var currentLocation: CLLocation?

    // MARK: -

    @Published private(set) var weatherData: WeatherData?
    @Published private(set) var weatherDataError: WeatherDataError?

    ...

}

The RootViewController class should no longer invoke the view model's fetchWeatherData(for:_:) method. We mark it as private with the private keyword.

// MARK: - Networking

private func fetchWeatherData(for location: CLLocation, _ completion: @escaping WeatherDataResult) {
    ...
}

By making fetchWeatherData(for:_:) a private method, the completion handler no longer serves a purpose. The root view model no longer passes the completion handler to the didFetchWeatherData(data:response:error:completion:) method either.

// MARK: - Networking

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) { (data, response, error) in
        DispatchQueue.main.async {
            self.didFetchWeatherData(data: data, response: response, error: error)
        }
    }

    // Start Data Task
    weatherDataTask?.resume()
}

Let's continue with the didFetchWeatherData(data:response:error:) method. Instead of executing the completion handler, the properties we created a few moments ago are updated. The weatherData property is set in one location. The weatherDataError property is set in three locations.

private func didFetchWeatherData(data: Data?, response: URLResponse?, error: Error?) {
    if let error = error {
        weatherDataError = .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
                weatherData = try decoder.decode(WeatherData.self, from: data)

            } catch {
                weatherDataError = .invalidResponse
                print("Unable to Decode Response, \(error)")
            }

        } else {
            weatherDataError = .failedRequest
        }

    } else {
        fatalError("Invalid Response")
    }
}

The root view model needs to respond to changes of its currentLocation property. We covered this earlier in this series. In the initializer of the RootViewModel class, we invoke a helper method, setupBindings().

// MARK: - Initialization

override init() {
    super.init()

    // Setup Bindings
    setupBindings()

    // Setup Notification Handling
    setupNotificationHandling()
}

In setupBindings(), the view model attaches a subscriber to the currentLocation publisher. Because it makes no sense to fetch weather data without a location, we apply the compactMap operator to filter out nil values. We covered this in the previous episode. The view model attaches a subscriber to the currentLocation publisher by invoking the sink(receiveValue:) method. We use a capture list to weakly reference the root view model in the closure that is passed to the sink(receiveValue:) method. Every time the location changes, the view model invokes its fetchWeatherData(for:) method, passing in the location. The subscription is added to the subscriptions property by calling the store(in:) method on the subscription.

// MARK: - Helper Methods

private func setupBindings() {
    $currentLocation
        .compactMap { $0 }
        .sink { [weak self] location in
            self?.fetchWeatherData(for: location)
        }.store(in: &subscriptions)
}

Before we refactor the RootViewController class, we remove the type alias, WeatherDataResult. We no longer need it.

import UIKit
import Combine
import CoreLocation

final class RootViewModel: NSObject {

    // MARK: - Properties

    @Published private(set) var currentLocation: CLLocation?

    // MARK: -

    @Published private(set) var weatherData: WeatherData?
    @Published private(set) var weatherDataError: WeatherDataError?

    ...

}

Refactoring the Root View Controller

With the changes of the RootViewModel class in place, updating the root view controller is straightforward. Open RootViewController.swift and navigate to the setupBindings() method.

The view controller attaches a subscriber to the weatherData publisher of its view model. It applies the compactMap operator to filter out nil values and invokes the sink(receiveValue:) method to attach a subscriber. We use a capture list to weakly reference the root view controller in the closure that is passed to the sink(receiveValue:) method and copy the body of the success case of the fetchWeatherData(for:) method. The subscription is added to the subscriptions property by calling the store(in:) method on the subscription. That's it.

// MARK: - Helper Methods

private func setupBindings() {
    viewModel?.$weatherData
        .compactMap { $0 }
        .sink(receiveValue: { [weak self] weatherData in
            // Configure Day View Controller
            self?.dayViewController.viewModel = DayViewModel(weatherData: weatherData)

            // Configure Week View Controller
            self?.weekViewController.viewModel = WeekViewModel(weatherData: weatherData.dailyData)
        }).store(in: &subscriptions)
}

We use the same pattern to subscribe to the weatherDataError publisher of the RootViewModel class. We copy the body of the failure case of the fetchWeatherData(for:) method.

// MARK: - Helper Methods

private func setupBindings() {
    viewModel?.$weatherData
        .compactMap { $0 }
        .sink(receiveValue: { [weak self] weatherData in
            // Configure Day View Controller
            self?.dayViewController.viewModel = DayViewModel(weatherData: weatherData)

            // Configure Week View Controller
            self?.weekViewController.viewModel = WeekViewModel(weatherData: weatherData.dailyData)
        }).store(in: &subscriptions)

    viewModel?.$weatherDataError
        .compactMap { $0 }
        .sink(receiveValue: { [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)
            }

            // Update Child View Controllers
            self?.dayViewController.viewModel = nil
            self?.weekViewController.viewModel = nil
        }).store(in: &subscriptions)
}

With these changes, we can remove the fetchWeatherData(for:) method and the import statement for the Core Location framework.

Restoring Pull-to-Refresh

We need to make a few more changes to restore the pull-to-refresh feature. Navigate to the controllerDidRefresh(controller:) method of the WeekViewControllerDelegate protocol in RootViewController.swift. Every time the week view controller invokes the controllerDidRefresh(controller:) method, the root view controller invokes refreshData() on its view model.

extension RootViewController: WeekViewControllerDelegate {

    func controllerDidRefresh(controller: WeekViewController) {
        viewModel?.refreshData()
    }

}

Let's implement the refreshData() method in RootViewModel.swift. We keep the implementation simple. Refreshing data means in this scenario that the root view model asks the location manager for the current location, ignoring the value of its currentLocation property. The root view model automatically fetches weather data for the current location when the value of its currentLocation property changes. This means that the root view model needs to invoke its requestLocation() method in refreshData(). That's it.

// MARK: - Public API

func refreshData() {
    requestLocation()
}

Build and run the application to see the result.

What's Next?

The Combine framework has helped us switch from a pulling mechanism to a pushing mechanism. This means that the root view controller is no longer concerned with location changes. The root view model decides when it is appropriate to fetch data. The root view controller simply displays the data it receives from its view model.