61

Enum-Driven TableView Development

 5 years ago
source link: https://www.tuicool.com/articles/hit/UzANZbQ
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

EnumDrivenTableViewController-feature.png

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:

ViewController

This tutorial assumes some familiarity with UITableView and Swift enums. If you need help, take a look at theiOS andSwift 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.

initial-loaded-state-1-281x500.png

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.

basic-confused-2-250x250.png

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:

broken-loading-state-281x500.png

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.

fixed-loading-state-281x500.png

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:

error-state-281x500.png

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:

broken-populated-state-281x500.png

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!

mascot-swift_love-320x320.png

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!

AllStates2.png

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:

after-property-observer-281x500.png

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:

no-pagination.gif

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

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:

pagination-state-281x500.png

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:

pagination-state-2-281x500.png

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:

pagination-no-appending.gif

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:

pagination-complete3.gif

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.

Download Materials


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK