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.
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.
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 totrue
. - The view tells the user that something went wrong when
error
is non-nil
. - If the
recordings
array isnil
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:
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:
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:
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:
- Add a new state called
paging
. - If the response from
networkingService.fetchRecordings
indicates that there are more pages, then set the state to.paging
. - 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
. - 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
Setting the State to .paging
In update(response:)
, replace state = .populated(newRecordings)
with this:
if response.hasMorePages {
state = .paging(newRecordings, next: response.nextPage)
} else {
state = .populated(newRecordings)
}
response.hasMorePages
tells you if the total number of pages that the API has for the current query is less than the current page. If there are more pages to be fetched, you set the state to .paging
. If the current page is the last page or the only page, then set the state to .populated
.
Build and run the app:
If you search for something with multiple pages, the app displays the loading indicator at the bottom. But if you search for a term that has only one page of results, you would get the usual .populated
state without the loading indicator.
You can see when there are more pages to be loaded, but the app isn’t doing anything to load them. You’ll fix that now.
Loading the Next Page
When the user is about to reach the end of the list, you want the app to start loading the next page. First, create a new empty method named loadPage
:
func loadPage(_ page: Int) {
}
This is the method that you’ll call when you want to load a particular page of results from the NetworkingService
.
Remember how loadRecordings()
was loading the first page by default? Move all the code from loadRecordings()
to loadPage(_:)
, except for the first line where the state is set to .loading
.
Next, update fetchRecordings(matching: query, page: 1)
to use the page parameter, like this:
networkingService.fetchRecordings(matching: query, page: page)
loadRecordings()
is looking a little bare now. Update it to call loadPage(_:)
, specifying page 1 as the page to be loaded:
@objc func loadRecordings() {
state = .loading
loadPage(1)
}
Build and run the app:
If nothing has changed, you’re on the right track!
Add the following to tableView(_: cellForRowAt:)
, just before the return
statement.
if case .paging(_, let nextPage) = state,
indexPath.row == state.currentRecordings.count - 1 {
loadPage(nextPage)
}
If the current state is .paging
, and the current row to be displayed is the same index as the last result in the currentRecordings
array, it’s time to load the next page.
Build and run the app:
Exciting! When the loading indicator comes into view, the app fetches the next page of data. But it doesn’t append the data to the current recordings — it just replaces the current recordings with the new ones.
Appending the Recordings
In update(response:)
, the newRecordings
array is being used for the view’s new state. Before the if response.hasMorePages
statement, add this:
var allRecordings = state.currentRecordings
allRecordings.append(contentsOf: newRecordings)
You get the current recordings and then append to new recordings to that array. Now, update the if response.hasMorePages
statement to use allRecordings
instead of newRecordings
:
if response.hasMorePages {
state = .paging(allRecordings, next: response.nextPage)
} else {
state = .populated(allRecordings)
}
See, how easy was it with the help of the state enum? Build and run the app to see the difference:
Where to Go From Here?
If you want to download the finished project, use the Download Materials button at the top or bottom of this tutorial.
In this tutorial, you refactored an app to handle complexity in a much clearer way. You replaced a lot of error-prone, poorly defined state with a clean and simple Swift enum. You even tested out your enum-driven table view by adding a complicated new feature: pagination.
When you refactor code, it’s important to test things to make sure that you haven’t broken anything. Unit tests are great for this. Take a look at the iOS Unit Testing and UI Testing tutorial to learn more.
Now that you’ve learned how to work with a pagination API in an app, you can learn how to build the actual API. The Server Side Swift with Vapor video course can get you started.
Did you enjoy this tutorial? I hope it’s useful to manage the states of all the apps you’ll build! If you have any questions or insights to share, I’d love to hear from you in the comments forum below.
The post Enum-Driven TableView Development appeared first on Ray Wenderlich.