Simple UIViewController State Machine

How to easily change between different views

Pexels

It is very common for us as mobile developers to have to implement some kind of content view (usually a list) showing data that has to be fetched from a backend service. Additionally, it is also common to have to react to fetch results with some kind of error or empty view.

Having this requirement, at the beginning of iOS development, developers were used to have a setup very similar to this:

  • A UIViewController + UITableView to show the content list, with a UIActivityIndicator while waiting.
  • A UIView representing the error state.
  • Another UIView representing empty state.

When fetching succeeds then we do nothing besides hiding the loading indicator; but if fetching ends on error or returns an empty dataset then we would have to:

  1. Hide the UITableView.
  2. Create an instance of the error or empty view to show, usually setting list view controller as its delegate.
  3. Add the subview to the list view hierarchy.

Although this approach is not wrong, it is not the desired way to do things in terms of scalability and maintenance:

  • We have to keep specific logic to determine when to show or to hide each view.
  • It also forces us to couple content view to the other views if there are delegations to do.
  • It involves quite a bit of work when a new state is added, usually using smelly switch statements.
  • It has higher memory footprint as more than one view lives at the same time.

Fortunately, iOS 5.0 was shipped with a new API to manage view controllers as children of other view controller, which opened the possibility to do our own container view controllers (as native UINavigationController, UITabBarController and so on) and to reuse components in a better way in terms of view lifecycle, allowing composing complex views using UIViewController instances.

In this article I am going to show a very simple approach on how I model view state using children view controllers.


First, let’s define our possible states:

enum State {
case content
case error
case empty
}
  • content, which shows the downloaded data.
  • error, which shows an error view with a “Retry” button (to fetch again).
  • empty, which shows some kind of image to indicate an empty dataset.

Having children view controllers or not, the first thing that came to our mind when dealing with state is to use the State design pattern. The state design pattern is part of the seminal Gang of Four work, and models a state machine in code.

State Design Pattern. Wikipedia

Although there is nothing wrong with this approach, depending on the use case GoF design patterns could be a bit over-engineered, and they add complexity to a solution that could be done in an easier way (but maybe less academic).

I am not going to deep dive on how to solve our problem with a State pattern because there are a lot of resources on the Internet on how to do it (also in Swift). I am going to show a simpler approach:

Thanks to Vladislav Simovic

These are the components involved:

  • StateProvider, which is a protocol that should be implemented by the actual type in charge of providing a view controller per each state (if needed) and to request state changes.
protocol StateProvider: AnyObject {
var initialState: State { get }
var title: String { get }
var stateChanger: StateChanger? { get set }
    func contentViewController() -> UIViewController
func errorViewController() -> UIViewController?
func emptyViewController() -> UIViewController?
}
  • StateContainerViewController, a concrete UIViewController in charge of adding and removing children depending on the state. It is also the implementer of the StateChanger protocol.
  • StateChanger, which is the type to call when the state has to be changed.
protocol StateChanger: AnyObject {
func changeTo(state: State)
}

In order to actually manage the state we have to create a type conforming to StateProvider, in our example we call it BooksStateProvider:

class BooksStateProvider: StateProvider {
var initialState: State = .content
var title: String = "Books"

weak var stateChanger: StateChanger?
    func contentViewController() -> UIViewController
// Create and return a BookListViewController instance
}
    func errorViewController() -> UIViewController? {
// Create and return an ErrorViewController instance
}
    func emptyViewController() -> UIViewController? {
// Create and return an EmptyViewController instance
}
}

In our example, the view controllers involved are BookListViewController, ErrorViewController and EmptyViewController. These types should expose a delegate protocol to notify some changes that could involve, among other particular things, changes in the state. We declare two delegates, as the empty state has nothing to delegate:

protocol BookListViewControllerDelegate: AnyObject {
func didFailFetching()
func didReceiveNoData()
}
protocol ErrorViewControllerDelegate: AnyObject {
func didRetry()
}

With those in place, now we can make our BooksStateProvider the delegate of these protocols and then be able to notify state change:

extension BooksStateProvider: BookListViewControllerDelegate {
func didFailFetching() {
stateChanger?.changeTo(state: .error)
}

func didReceiveNoData() {
stateChanger?.changeTo(state: .empty)
}
}
extension BooksStateProvider: ErrorViewControllerDelegate {
func didRetry() {
stateChanger?.changeTo(state: .content)
}
}

The last step is to create the actual container view controller, passing our state provider:

let stateProvider = BooksStateProvider()
let stateContainerViewController = StateContainerViewController(stateProvider: stateProvider)

When initializing the StateContainerViewController, which is also the StateChanger implementer, the provider’s changer is injected:

init(stateProvider: ViewControllerStateProvider) {
self.stateProvider = stateProvider

super.init(nibName: nil, bundle: nil)

self.stateProvider.stateChanger = self
self.title = self.stateProvider.title
}

And that’s all. Now, with delegates properly connected, our StateContainerViewController will be able to change between states seamlessly.

Here it is a running demo:

Some notes:

  • Probably some of you have noticed there is no loading state; this is on purpose. Per my experience, having a loading state separated from the content makes things a bit more complicated (in terms of delegation), and stops the possibility of using content plus an indicator view without being contained in a state machine.
    To avoid having to add a loading view per each content view we can use POP and create a trait to be used from view controllers; this way BookListViewController can show or hide the loading view easily. You can check this solution in the example project, here.
  • I decided to not to allow inner view controllers to change the state getting its parent StateContainerViewController in the style of self.navigationController. If so, children view controllers will be coupled to its container and we will be unable to use them without a state container.
  • When retry button is tapped, the content is shown again. This is done because our view controllers are independent, and BookListViewController is the only one which knows how to fetch its data.
  • Our StateProvider role could be played by a Coordinator, which can do more than just changing states.

As said before, this is a very simple approach but with some benefits:

  • Inner view controllers (content, error and empty) can be used and reused independently, even when not in a state machine. Each one is on charge of its own stuff without knowing or involving any other.
  • As inner view controllers do delegation in a regular way, they are not tied to any state container. StateProvider is the one that links both worlds.

There are some cons, too:

  • This setup is coupled to the defined states; it is not a generic view controller state machine.
  • A new state means modifying two of our three collaborators.

This solution balances convenience and extensibility. Every time I had to achieve this type of setup, this solution worked for me, and could be easily extended with almost no additional work.

You can see the running example code in my Github:

Thanks for reading!