In this episode, we optimize the implementation of the day view controller and the day view model. There are a few details we need to take care of. First, the day view controller should display an error message when its view model emits an error. Second, the day view controller should only display its activity indicator view if it has no weather data to display. Let's tackle these problems one by one.

Displaying an Error Message

We have a number of options to display an error message to the user. We can change the Output type of weatherDataErrorPublisher to an optional type. The day view controller displays an error message if the publisher emits an error and doesn't display an error message if the publisher emits nil. I don't like this option because it means the day view model needs to explicitly emit nil to hide the error message. There are two other options I would like to explore in this and the next episode. The first one is simple and requires few changes.

Open DayViewController.swift and navigate to the setupBindings() method. Let's start with the weather data container view. When should the weather data container view be visible and when should it be hidden. The view should be hidden when there is no weather data to display and the view should be visible when there is weather data to display. That is obvious. The weather data container view should also be hidden when the day view controller displays an error message. We need a solution that meets these requirements.

The day view controller already subscribes to the view model's loadingPublisher to show and hide the weather data container view. We need to make a small change to the implementation. The weather data container view should be hidden when loadingPublisher emits true. This is easy to accomplish by applying the filter operator. Shorthand argument syntax makes the change concise and easy to understand.

viewModel?.loadingPublisher
    .filter { $0 }
    .assign(to: \.isHidden, on: weatherDataContainerView)
    .store(in: &subscriptions)

The filter operator accepts a closure that returns true or false. The closure is executed for every value the upstream publisher emits and accepts the value as an argument. If the closure returns true, then the filter operator republishes the value of the upstream publisher. The filter operator ignores the value if the closure returns false. The behavior of the filter operator is similar to that of the filter(_:) function of the Swift Standard Library.

We can keep the body of the closure that is passed to the filter operator simple because loadingPublisher emits boolean values and we only want to republish values that are equal to true.

We repeat this pattern for the message label that displays errors. The only difference is the object the day view model passes to the assign(to:on:) method.

viewModel?.loadingPublisher
    .filter { $0 }
    .assign(to: \.isHidden, on: messageLabel)
    .store(in: &subscriptions)

The weather data container view and message label are hidden if the root view model is fetching weather data. The weather data container view should also be hidden if the day view controller displays an error message. We can accomplish this with the map operator. The day view controller subscribes to the view model's weatherDataErrorPublisher and uses the map operator to emit true every time the publisher emits a value. It ignores the value weatherDataErrorPublisher emits, publishing true instead. We use the assign(to:on:) method to set the isHidden property of weatherDataContainerView.

viewModel?.weatherDataErrorPublisher
    .map { _ in true }
    .assign(to: \.isHidden, on: weatherDataContainerView)
    .store(in: &subscriptions)

We use the same technique to hide the message label when the day view controller displays weather data. The day view controller applies the map operator to emit true every time the view model's weatherDataPublisher emits weather data. We use the assign(to:on:) method to set the isHidden property of messageLabel.

viewModel?.weatherDataPublisher
    .map { _ in true }
    .assign(to: \.isHidden, on: messageLabel)
    .store(in: &subscriptions)

The weather data container view should be visible if weatherDataPublisher emits weather data. We use the same technique we used earlier. The day view controller subscribes to the view model's weatherDataPublisher and uses the map operator to emit false every time the publisher emits a value, ignoring the value weatherDataPublisher emits. We use the assign(to:on:) method to set the isHidden property of weatherDataContainerView.

viewModel?.weatherDataPublisher
    .map { _ in false }
    .assign(to: \.isHidden, on: weatherDataContainerView)
    .store(in: &subscriptions)

We tweak these steps to show the message label if weatherDataErrorPublisher emits an error. The day view controller subscribes to the view model's weatherDataErrorPublisher and uses the map operator to emit false every time the publisher emits a value, ignoring the value weatherDataErrorPublisher emits. We use the assign(to:on:) method to set the isHidden property of messageLabel.

viewModel?.weatherDataErrorPublisher
    .map { _ in false }
    .assign(to: \.isHidden, on: weatherDataContainerView)
    .store(in: &subscriptions)

Before we build and run the application, we need to clean up the DayViewController class. We rename updateView() to setupView() and only set the text property of messageLabel. We no longer need the rest of the implementation.

// MARK: - View Life Cycle

override func viewDidLoad() {
    super.viewDidLoad()

    // Setup Bindings
    setupBindings()

    // Update View
    setupView()
}

// MARK: - View Methods

private func setupView() {
    // Configure Message Label
    messageLabel.text = "Cloudy was unable to fetch weather data."
}

We can also remove the reloadData() method because the user interface of the DayViewController class is automatically updated when the view model's publishers emit data. Build and run the application to see the result.

Don't Repeat Yourself

Even though the solution we implemented works, it is difficult to understand what happens when one of the publishers emits a value. We can improve the implementation slightly by replacing the assign(to:on:) method with the sink(receiveValue:) method. Every time loadingPublisher emits true, weatherDataContainerView and messageLabel are hidden.

viewModel?.loadingPublisher
    .filter { $0 }
    .sink { [weak self] _ in
        self?.weatherDataContainerView.isHidden = true
        self?.messageLabel.isHidden = true
    }
    .store(in: &subscriptions)

The same technique can be used to show and hide weatherDataContainerView and messageLabel when weatherDataPublisher and weatherDataErrorPublisher emit values.

viewModel?.weatherDataPublisher
    .sink { [weak self] _ in
        self?.weatherDataContainerView.isHidden = false
        self?.messageLabel.isHidden = true
    }
    .store(in: &subscriptions)

viewModel?.weatherDataErrorPublisher
    .sink { [weak self] _ in
        self?.weatherDataContainerView.isHidden = true
        self?.messageLabel.isHidden = false
    }.store(in: &subscriptions)

This option reduces duplication, but it is still difficult to understand what happens when one of the publishers emits a value. The day view controller's user interface is defined by three variables:

  • Is the application fetching data?
  • Does the application have weather data to display?
  • Did the application encounter an error?

The combination of these variables defines the day view controller's user interface.

What's Next?

The assign(to:on:) method is convenient and elegant to drive the user interface of an application, but it isn't always the best solution. If you are using the assign(to:on) method multiple times to update the same property of the same object, then you need to take a step back and consider other options. We ran into this issue in this episode. The sink(receiveValue:) method helped us address this problem, but the solution still suffers from duplication. In the next episode, we implement a more robust solution.