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
You are currently viewing page 2 of 3 of this article. Click here to view the first page.

Refactoring the Empty and Populated States

There’s a pretty long if-else chain at the beginning of update(response:). To clean this up, replace update(response:) with the following:

func update(response: RecordingsResult) {
  if let error = response.error {
    state = .error(error)
    setFooterView()
    tableView.reloadData()
    return
  }
  
  recordings = response.recordings
  tableView.reloadData()
}

You’ve just broken the states populated and empty. Don’t worry, you’ll fix them soon!

Setting the Correct State

Add this below the if let error = response.error block:

guard let newRecordings = response.recordings,
  !newRecordings.isEmpty else {
    state = .empty
    setFooterView()
    tableView.reloadData()
    return
}

Don’t forget to call setFooterView() and tableView.reloadData() when updating the state. If you miss it, you won’t see the changes.

Next, find this line inside of update(response:):

recordings = response.recordings

Replace it with this:

state = .populated(newRecordings)
setFooterView()

You’ve just refactored update(response:) to act on the view controller’s state property.

Setting the Footer View

Next, you need to set the correct table footer view for the current state. Add these two cases to the switch statement inside setFooterView():

case .empty:
  tableView.tableFooterView = emptyView
case .populated:
  tableView.tableFooterView = nil

The app no longer uses the default case, so remove it.

Build and run the app to see what happens:

Getting Data from the State

The app isn’t displaying data anymore. The view controller’s recordings property populates the table view, but it isn’t being set. The table view needs to get its data from the state property now. Add this computed property inside the declaration of the State enum:

var currentRecordings: [Recording] {
  switch self {
  case .populated(let recordings):
    return recordings
  default:
    return []
  }
}

You can use this property to populate the table view. If the state is .populated, it uses the populated recordings; otherwise, it returns an empty array.

In tableView(_:numberOfRowsInSection:), remove this line:

return recordings?.count ?? 0

And replace it with the following:

return state.currentRecordings.count

Next up, in tableView(_:cellForRowAt:), remove this block:

if let recordings = recordings {
  cell.load(recording: recordings[indexPath.row])
}

Replace it with this:

cell.load(recording: state.currentRecordings[indexPath.row])

No more unnecessary optionals!

You don’t need the recordings property of MainViewController anymore. Remove it along with its final reference in loadRecordings().

Build and run the app.

All the states should be working now. You’ve removed the isLoading, error, and recordings properties in favor of one clearly defined state property. Great job!

Keeping in Sync with a Property Observer

You’ve removed the poorly defined state from the view controller, and you can now easily discern the view’s behavior from the state property. Also, it’s impossible to be in both a loading and an error state — that means no chance of invalid state.

There’s still one problem, though. When you update the value of the state property, you must remember to call setFooterView() and tableView.reloadData(). If you don’t, the view won’t update to properly reflect the state that it’s in. Wouldn’t it be better if everything was refreshed whenever the state changed?

This is a great opportunity to use a didSet property observer. You use a property observer to respond to a change in a property’s value. If you want to reload the table view and set the footer view every time the state property is set, then you need to add a didSet property observer.

Replace the declaration of var state = State.loading with this:

var state = State.loading {
  didSet {
    setFooterView()
    tableView.reloadData()
  }
}

When the value of state is changed, then the didSet property observer will fire. It calls setFooterView() and tableView.reloadData() to update the view.

Remove all other calls to setFooterView() and tableView.reloadData(); there are four of each. You can find them in loadRecordings() and update(response:). They’re not needed anymore.

Build and run the app to check that everything still works:

Adding Pagination

When you use the app to search, the API has many results to give but it doesn’t return all results at once.

For example, search Chirper for a common species of bird, something that you’d expect to see many results for — say, a parrot:

Search parrot view

That can’t be right. Only 50 recordings of parrots?

The xeno-canto API limits the results to 500 at a time. Your project app cuts that amount to 50 results within NetworkingService.swift, just to make this example easy to work with.

If you only receive the first 500 results, then how do you get the rest of the results? The API that you’re using to retrieve the recordings does this through pagination.

How an API Supports Pagination

When you query the xeno-canto API within the NetworkingService, this is what the URL looks like:

http://www.xeno-canto.org/api/2/recordings?query=parrot

The results from this call are limited to the first 500 items. This is referred as the first page, which contains items 1–500. The next 500 results would be referred to as the second page. You specify which page you want as a query parameter:

http://www.xeno-canto.org/api/2/recordings?query=parrot&page=2

Notice the &page=2 on the end; this code tells the API that you want the second page, which contains the items 501–1000.

Supporting Pagination in Your Table View

Take a look at MainViewController.loadRecordings(). When it calls networkingService.fetchRecordings(), the page parameter is hard coded to 1. This is what you need to do:

  1. Add a new state called paging.
  2. If the response from networkingService.fetchRecordings indicates that there are more pages, then set the state to .paging.
  3. When the table view is about to display the last cell in the table, load the next page of results if the state is .paging.
  4. Add the new recordings from the service call to the array of recordings.

When the user scrolls to the bottom, the app will fetch more results. This gives the impression of an infinite list — sort of like what you’d see in a social media app. Pretty cool, huh?

Adding the New Paging State

Start by adding the new paging case to your state enum:

case paging([Recording], next: Int)

It needs to keep track of an array of recordings to display, just like the .populated state. It also needs to keep track of the next page that the API should fetch.

Try to build and run the project, and you’ll see that it no longer compiles. The switch statement in setFooterView is exhaustive, meaning that it covers all cases without a default case. This is great because it ensures that you update it when a new state is added. Add this to the switch statement:

case .paging:
  tableView.tableFooterView = loadingView

If the app is in the paging state, it displays the loading indicator at the end of the table view.

The state’s currentRecordings computed property isn’t exhaustive though. You’ll need to update it if you want to see your results. Add a new case to the switch statement inside currentRecordings:

case .paging(let recordings, _):
  return recordings