Enum-Driven TableView Development

In this tutorial, you will learn how to use Swift enums to handle the different states of your app to populate a table view. By Keegan Rush.

Leave a rating/review
Download materials
Save for later
Share

Is there anything more fundamental, in iOS development, than UITableView? It’s a simple, clean control. Unfortunately, a lot of complexity lies under the hood: Your code needs to show loading indicators at the right time, handle errors, wait for service call completions and show results when they come in.

In this tutorial, you’ll learn how to use Enum-Driven TableView Development to manage this complexity.

To follow this technique, you’ll refactor an existing app called Chirper. Along the way, you’ll learn the following:

  • How to use an enum to manage the state of your ViewController.
  • The importance of reflecting the state in the view for the user.
  • The dangers of poorly defined state
  • How to use property observers to keep your view up-to-date.
  • How to work with pagination to simulate an endless list of search results.
This tutorial assumes some familiarity with UITableView and Swift enums. If you need help, take a look at the iOS and Swift tutorials first.

Getting Started

The Chirper app that you’ll refactor for this tutorial presents a searchable list of bird sounds from the xeno-canto public API.

If you search for a species of bird within the app, it will present you with a list of recordings that match your search query. You can play the recordings by tapping the button in each row.

To download the starter project, use the Download Materials button at the top or bottom of this tutorial. Once you’ve downloaded this, open the starter project in Xcode.

Chirper app

Different States

A well-designed table view has four different states:

  • Loading: The app is busy fetching new data.
  • Error: A service call or another operation has failed.
  • Empty: The service call has returned no data.
  • Populated: The app has retrieved data to display.

The state populated is the most obvious, but the others are important as well. You should always let the user know the app state, which means showing a loading indicator during the loading state, telling the user what to do for an empty data set and showing a friendly error message when things go wrong.

To start, open MainViewController.swift to take a look at the code. The view controller does some pretty important things, based on the state of some of its properties:

  • The view displays a loading indicator when isLoading is set to true.
  • The view tells the user that something went wrong when error is non-nil.
  • If the recordings array is nil or empty, the view displays a message prompting the user to search for something different.
  • If none of the previous conditions are true, the view displays the list of results.
  • tableView.tableFooterView is set to the correct view for the current state.

There’s a lot to keep in mind while modifying the code. And, to make things worse, this pattern gets more complicated when you pile on more features through the app.

Poorly Defined State

Search through MainViewController.swift and you’ll see that the word state isn’t mentioned anywhere.

The state is there, but it’s not clearly defined. This poorly defined state makes it hard to understand what the code is doing and how it responds to the changes of its properties.

Invalid State

If isLoading is true, the app should show the loading state. If error is non-nil, the app should show the error state. But what happens if both of these conditions are met? You don’t know. The app would be in an invalid state.

MainViewController doesn’t clearly define its states, which means it may have some bugs due to invalid or indeterminate states.

A Better Alternative

MainViewController needs a better way to manage its state. It needs a technique that is:

  • Easy to understand
  • Easy to maintain
  • Insusceptible to bugs

In the steps that follow, you’re going to refactor MainViewController to use an enum to manage its state.

Refactoring to a State Enum

In MainViewController.swift, add this above the declaration of the class:

enum State {
  case loading
  case populated([Recording])
  case empty
  case error(Error)
}

This is the enum that you’ll use to clearly define the view controller’s state. Next, add a property to MainViewController to set the state:

var state = State.loading

Build and run the app to see that it still works. You haven’t made any changes to the behavior yet so everything should be the same.

Refactoring the Loading State

The first change you’ll make is to remove the isLoading property in favor of the state enum. In loadRecordings(), the isLoading property is set to true. The tableView.tableFooterView is set to the loading view. Remove these two lines from the beginning of loadRecordings():

isLoading = true
tableView.tableFooterView = loadingView

Replace it with this:

state = .loading

Then, remove self.isLoading = false inside the fetchRecordings completion block. loadRecordings() should look like this:

@objc func loadRecordings() {
  state = .loading
  recordings = []
  tableView.reloadData()
    
  let query = searchController.searchBar.text
  networkingService.fetchRecordings(matching: query, page: 1) { [weak self] response in
      
    guard let `self` = self else {
      return
    }
      
    self.searchController.searchBar.endEditing(true)
    self.update(response: response)
  }
}

You can now remove MainViewController’s isLoading property. You won’t need it any more.

Build and run the app. You should have the following view:

search view without loading state

The state property has been set, but you’re not doing anything with it. tableView.tableFooterView needs to reflect the current state. Create a new method in MainViewController named setFooterView().

func setFooterView() {
  switch state {
  case .loading:
    tableView.tableFooterView = loadingView
  default:
    break
  }
}

Now, back to loadRecordings(). After setting the state to .loading, add the following:

setFooterView()

Build and run the app.

Now when you change the state to loading setFooterView() is called and the progress indicator is displayed. Great job!

Refactoring the Error State

loadRecordings() fetches recordings from the NetworkingService. It takes the response from networkingService.fetchRecordings() and calls update(response:), which updates the app’s state.

Inside update(response:), if the response has an error, it sets the error’s description on the errorLabel. The tableFooterView is set to the errorView, which contains the errorLabel. Find these two lines in update(response:):

errorLabel.text = error.localizedDescription
tableView.tableFooterView = errorView

Replace them with this:

state = .error(error)
setFooterView()

In setFooterView(), add a new case for the error state:

case .error(let error):
  errorLabel.text = error.localizedDescription
  tableView.tableFooterView = errorView

The view controller no longer needs its error: Error? property. You can remove it. Inside update(response:), you need to remove the reference to the error property that you just removed:

error = response.error

Once you’ve removed that line, build and run the app.

You’ll see that the loading state still works well. But how do you test the error state? The easiest way is to disconnect your device from the internet; if you’re running the simulator on your Mac, disconnect your Mac from the internet now. This is what you should see when the app tries to load data:

No connection view