The Published property wrapper lowers the barrier to start integrating the Combine framework into a project. There are a few details you need to take into account, though. Remember from the previous episode that we need to address two issues. Let's look at the first issue.
Filtering Out Nil Values
The initial value of the currentLocation property is equal to nil and that is the first value the currentLocation publisher emits. The view controller shouldn't invoke its fetchWeatherData() method if the currentLocation publisher emits nil. It makes no sense to request weather data if the currentLocation property of the view model is equal to nil. We have several options to address this. I show you two.
Filter Operator
Because we need to filter out nil values, applying the filter operator to the currentLocation publisher seems obvious. Right? The filter operator accepts a closure as its only argument. The function you pass to a Combine operator should preferably be a pure function. We discussed pure functions earlier in this series.
The idea is simple. The closure you pass to the filter operator is executed every time the currentLocation publisher emits a value. The closure accepts the value as an argument and returns true or false. If it returns true, then the value is republished to the downstream subscriber. If it returns false, then the value is not republished to the downstream subscriber. Let's put this into practice.
We apply the filter operator to the currentLocation publisher, passing in a closure. If location is not equal to nil, the closure returns true and the CLLocation object is republished to the downstream subscriber. If location is equal to nil, the closure returns false and the CLLocation object is not republished to the downstream subscriber. That's it.
// MARK: - Helper Methods
private func setupBindings() {
viewModel?.$currentLocation
.filter({ location -> Bool in
location != nil
})
.sink(receiveValue: { [weak self] _ in
self?.fetchWeatherData()
}).store(in: &subscriptions)
}
We can shorten the implementation by using the shorthand argument name and trailing closure syntax. This looks quite nice.
// MARK: - Helper Methods
private func setupBindings() {
viewModel?.$currentLocation
.filter { $0 != nil }
.sink(receiveValue: { [weak self] _ in
self?.fetchWeatherData()
}).store(in: &subscriptions)
}
We can achieve the same result by applying the compactMap operator. Which approach you take is up to you. Like the filter operator, the compactMap operator accepts a closure as its only argument. The closure that is passed to the compactMap operator accepts the location as an argument and returns an object of type CLLocation?. The compactMap operator republishes the return value to the downstream subscriber if it has a value.
We apply the compactMap operator to the currentLocation publisher, passing in a closure. The body of the closure is as simple as it gets. We simply return the argument that is passed to the closure. Notice that we keep the implementation short and elegant using the shorthand argument name and trailing closure syntax.
// MARK: - Helper Methods
private func setupBindings() {
viewModel?.$currentLocation
.compactMap { $0 }
.sink(receiveValue: { [weak self] _ in
self?.fetchWeatherData()
}).store(in: &subscriptions)
}
Using the compactMap operator to filter out nil values is a pattern I use often. You can use the filter operator if you prefer, but it is important to be aware of the difference. Even though the filter operator filers out nil values, the value that is republished is of type CLLocation?, an optional. The compactMap operator also filters out nil values, but the value that is republished is of type CLLocation. The difference is subtle but not unimportant.
Working Around the Quirks of the Published Property Wrapper
The second issue we need to address requires a bit more work. The moment the currentLocation publisher emits a new value, the currentLocation property hasn't been updated with the new value. We need to work around this problem. The solution is easier than you might think.
We currently ignore the value the currentLocation publisher emits. The view controller calls fetchWeatherData(_:) on its view model in its fetchWeatherData() method. To resolve the issue, the view controller should pass the location for which to fetch weather data to the fetchWeatherData(_:) method of the RootViewModel class. Let's implement this change.
Open RootViewModel.swift and navigate to the fetchWeatherData(_:) method. The first argument of fetchWeatherData(for:_:) is of type CLLocation, the location for which to fetch weather data. Because the fetchWeatherData(for:_:) method no longer relies on the value of the currentLocation property, we can remove the guard statement. That is a nice bonus.
// MARK: - Public API
func fetchWeatherData(for location: CLLocation, _ completion: @escaping WeatherDataResult) {
// 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, completion: completion)
}
}
// Start Data Task
weatherDataTask?.resume()
}
We also need to update the RootViewController class. Open RootViewController.swift and add an import statement for the Core Location framework at the top.
import UIKit
import Combine
import CoreLocation
final class RootViewController: UIViewController {
...
}
The updated fetchWeatherData(for:) method accepts a single argument of type CLLocation. It passes the CLLocation object to the fetchWeatherData(for:_:) method of the RootViewModel class.
private func fetchWeatherData(for location: CLLocation) {
// Fetch Weather Data for Location
viewModel?.fetchWeatherData(for: location) { [weak self] (result) in
...
}
}
The RootViewController class invokes the fetchWeatherData(for:) method in two places. In the setupBindings() method, the view controller no longer ignores the value the currentLocation publisher emits. In the closure that is passed to the sink(receiveValue:) method, the CLLocation object is passed to the fetchWeatherData(for:) method.
// MARK: - Helper Methods
private func setupBindings() {
viewModel?.$currentLocation
.compactMap { $0 }
.sink(receiveValue: { [weak self] location in
self?.fetchWeatherData(for: location)
}).store(in: &subscriptions)
}
The RootViewController class also invokes the fetchWeatherData(for:) method in controllerDidRefresh(controller:), a method of the WeekViewControllerDelegate protocol. The problem is that the RootViewController instance doesn't have a CLLocation object to pass to the fetchWeatherData(for:) method of its view model. We resolve this problem in the next episode.
More Problems to Solve
You may have noticed that the implementation of the RootViewController class can be improved. The root view controller is notified when the location changes and passes the location back to the view model to fetch weather data. That isn't ideal. There are a number of options to improve the implementation.
First, the root view controller shouldn't be aware of the CLLocation object. Adding an import statement for the Core Location framework to RootViewController.swift is a code smell. Second, the root view controller shouldn't need to ask its view model for weather data. The view model should expose a publisher that emits weather data and the root view controller should simply subscribe to that publisher. That is a more reactive approach that also benefits the separation of concerns of the Model-View-ViewModel pattern. Third, making these improvement allows us to restore the application's pull-to-refresh feature. The root view controller should simply ask its view model to refresh the data it displays. It is up to the view model to decide what refreshing data means.
What's Next?
Even though we are integrating Combine into Cloudy one step at a time, we can already see the benefits of adopting reactive programming. The Combine framework helps us to simplify the implementation of the RootViewController and RootViewModel classes.