• Dealing with Complex Table Views in iOS and Keeping Your Sanity
  • Marin Benčević ć ć
  • The Nuggets translation Project
  • Permanent link to this article: github.com/xitu/gold-m…
  • Translator: zhangqippp

Handle complex Table Views in iOS with elegance

Table Views is one of the most important layout components in iOS development. Usually some of our most important pages are table views: feed streams, Settings pages, list of items, etc.

Every iOS developer who has developed a complex Table View knows that such a table view can make code very rough very quickly. This results in a huge View Controller with lots of UITableViewDataSource methods and lots of if and switch statements. Add array indexing and the occasional out-of-bounds error, and you’ll get a lot of frustration in this code.

I’ll give you a few principles that I think are helpful (at least for now) and that have helped me solve a lot of problems. These suggestions are not just for complex table views; they apply to all your table views.

Let’s look at an example of a complex UITableView.

These great screenshot illustrations from LazyAmphy

This is PokeBall, a social network for Pokemon. Like other social networks, it needs a feed stream to display different events that are relevant to the user. These events include new photos and status messages, grouped by day. So, now we have two problems to worry about: the table View has different states, and the table View has multiple cells and sections.

1. Let the cell handle some logic

I’ve seen many developers put cell configuration logic into the cellForRowAt: method. If you think about it, the purpose of this method is to create a cell. The purpose of a UITableViewDataSource is to provide data. The purpose of the data source is not to set button fonts.

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
  let cell = tableView.dequeueReusableCell(
    withIdentifier: identifier,
    for: indexPath) as! StatusTableViewCell

  let status = statuses[indexPath.row]
  cell.statusLabel.text = status.text
  cell.usernameLabel.text = status.user.name

  cell.statusLabel.font = .boldSystemFont(ofSize: 16)
  return cell
}Copy the code

You should put the code that configures and sets the cell style into the cell. If it is something that will exist throughout the life of the cell, such as the font for a label, it should be placed in the awakeFromNib method.

class StatusTableViewCell: UITableViewCell {

  @IBOutlet weak var statusLabel: UILabel!
  @IBOutlet weak var usernameLabel: UILabel!

  override func awakeFromNib() {
    super.awakeFromNib()

    statusLabel.font = .boldSystemFont(ofSize: 16)
  }
}Copy the code

You can also set cell data by adding an observer to the property.

var status: Status! {
  didSet {
    statusLabel.text = status.text
    usernameLabel.text = status.user.name
  }
}Copy the code

That way your cellForRow method will be concise and readable.

func tableView(_ tableView: UITableView, 
  cellForRowAt indexPath: IndexPath) -> UITableViewCell {
  let cell = tableView.dequeueReusableCell(
    withIdentifier: identifier,
    for: indexPath) as! StatusTableViewCell
  cell.status = statuses[indexPath.row]
  return cell
}Copy the code

In addition, the cell setup logic is now placed in a separate place, rather than scattered between the cell and the View Controller.

2. Let the Model handle some logic

Typically, you populate a table View with a set of Model objects retrieved from a background service. The cell then needs to display different content based on the Model.

var status: Status! {
  didSet {
    statusLabel.text = status.text
    usernameLabel.text = status.user.name

    if status.comments.isEmpty {
      commentIconImageView.image = UIImage(named: "no-comment")}else {
      commentIconImageView.image = UIImage(named: "comment-icon")}if status.isFavorite {
      favoriteButton.setTitle("Unfavorite".for: .normal)
    } else {
      favoriteButton.setTitle("Favorite".for: .normal)
    }
  }
}Copy the code

You can create an object that matches the cell, pass in the model object mentioned above to initialize it, and calculate the title, image, and other properties needed in the cell.

class StatusCellModel {

  let commentIcon: UIImage
  let favoriteButtonTitle: String
  let statusText: String
  let usernameText: String

  init(_ status: Status) {
    statusText = status.text
    usernameText = status.user.name

    if status.comments.isEmpty {
      commentIcon = UIImage(named: "no-comments-icon")!
    } else {
      commentIcon = UIImage(named: "comments-icon")!
    }

    favoriteButtonTitle = status.isFavorite ? "Unfavorite" : "Favorite"}}Copy the code

Now you can move a lot of the logic that shows the cell into the Model. You can instantiate and unit test your model independently without having to do complex data emulation and cell fetching in unit tests. This also means that your cells will be very simple and easy to read.

var model: StatusCellModel! {
  didSet {
    statusLabel.text = model.statusText
    usernameLabel.text = model.usernameText
    commentIconImageView.image = model.commentIcon
    favoriteButton.setTitle(model.favoriteButtonTitle, for: .normal)
  }
}Copy the code

This is a pattern similar to MVVM, but applied to a single table View cell.

3. Use a matrix (but make it nice)

Just a regular iOS developer making some table views

Grouped table Views are often messy. Have you ever seen anything like this?

func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
  switch section {
  caseZero:return "Today"
  case 1: return "Yesterday"
  default: return nil
  }
}Copy the code

There are a lot of hard-coded indexes in this mass of code that should be simple and easy to change and transform. There is a simple solution to this problem: matrices.

Remember the matrix? Machine learning people and first-year computer science students use it a lot, but app developers don’t. If you think about a table view of groups, you’re actually showing a list of groups. Each group is a list of cells. It sounds like an array of arrays, or a matrix.

Matrices are the correct posture for grouping table views. Replace one-dimensional arrays with arrays of arrays. The UITableViewDataSource method is also organized this way: you are asked to return the NTH cell of the MTH group, not the NTH cell of the table View.

var cells: [[Status]] = [[]]

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
  let cell = tableView.dequeueReusableCell(
    withIdentifier: identifier,
    for: indexPath) as! StatusTableViewCell
  cell.status = statuses[indexPath.section][indexPath.row]
  return cell
}Copy the code

We can extend this idea by defining a grouping container type. This type not only holds the cell for a particular group, but also information like the group title.

struct Section {
  let title: String
  let cells: [Status]
}
var sections: [Section] = []Copy the code

Now that we can avoid the hard-coded indexes we used in the switch, we define a grouped array and return their titles directly.

func tableView(_ tableView: UITableView, 
  titleForHeaderInSection section: Int) -> String? {
  return sections[section].title
}Copy the code

There is less code in our data source methods, which in turn reduces the risk of out-of-bounds errors. The code becomes more expressive and readable.

Enumerations are your friends

Working with multiple cell types can sometimes be tricky. For example, in a feed stream, you have to display different types of cells, such as images and status information. To keep your code elegant and avoid weird array index calculations, you should store all types of data in the same array.

Arrays, however, are homogeneous, meaning you can’t store different types in the same array. The first solution to this problem is protocol. Swift, after all, is protocol-oriented.

You can define a FeedItem protocol and have all of our Cell’s Model objects follow this protocol.

protocol FeedItem {}
struct Status: FeedItem { ... }
struct Photo: FeedItem { ... }Copy the code

Then define an array that holds objects of type FeedItem.

var cells: [FeedItem] = []Copy the code

However, there is a small problem when implementing the cellForRowAt: method with this scenario.

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
  let cellModel = cells[indexPath.row]

  if let model = cellModel as? Status {
    let cell = ...
    return cell
  } else if let model = cellModel as? Photo {
    let cell = ...
    return cell
  } else {
    fatalError()
  }
}Copy the code

In making model objects comply with the protocol, you lose a lot of information that you actually need. You abstract the cell, but what you really need is a concrete instance. So, you eventually have to check to see if you can convert the Model object to a certain type before you can display the cell accordingly.

That works, but it’s not good enough. Casting down object types is inherently unsafe and produces optional types. You also can’t tell if all cases are covered, because there are an infinite number of types that can comply with your protocol. So you also need to call the fatalError method to handle unexpected types.

When you try to convert an instance of a protocol type to a specific type, the code doesn’t taste right. Using a protocol is when you don’t need specific information, just a subset of the original data can do the job.

A better implementation is to use enumerations. That way you can handle it with switch, and the code won’t compile if you don’t handle all the cases.

enum FeedItem {
  case status(Status)
  case photo(Photo)
}Copy the code

Enumerations can also have associated values, so you can put the required data in the actual values as well.

Arrays will still be defined that way, but your cellForRowAt: method will be much cleaner:

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
  let cellModel = cells[indexPath.row]

  switch cellModel {
  case .status(let status):
    let cell = ... 
    return cell
  case .photo(let photo):
    let cell = ...
    return cell
  }
}Copy the code

So you have no casts, no optional types, no unhandled cases, so no bugs.

5. Make the status clear

These great screenshot illustrations from LazyAmphy

A blank page can confuse the user, so we usually display some messages on the page when the Table View is empty. We also display a load flag when the data is loaded. But if something goes wrong with the page, it’s best to tell the user what happened so they know how to fix the problem.

Our Table View usually has all of these states, and sometimes more. Managing these states is a bit of a pain.

Let’s say you have two possible states: a display of data, or a view that tells the user that there is no data. A beginner developer might simply indicate the “no data” state by hiding the Table View and showing the no data view.

noDataView.isHidden = false
tableView.isHidden = trueCopy the code

Changing state in this case means that you are modifying two Boolean properties. In another part of the View Controller, you might want to change this state, but you have to keep in mind that you need to change both properties at the same time.

In fact, the two Booleans always change in sync. You can’t show some data in a list while showing no data views.

It is important to think about the difference between the value of a state in practice and the value of a state that might occur in an application. There are four possible combinations of two Booleans. This means that you have two invalid states, and at some point you might end up with these invalid state values, and you have to deal with that contingency.

You can solve this problem by defining a State enumeration that enumerates only the possible states of your page.

enum State {
  case noData
  case loaded
}
var state: State = .noDataCopy the code

You can also define a separate state property as the only entry point to change the state of the page. Whenever this property changes, you update the page to the corresponding state.

var state: State = .noData {
  didSet {
    switch state {
    case .noData:
      noDataView.isHidden = false
      tableView.isHidden = true
    case .loaded:
      noDataView.isHidden = false
      tableView.isHidden = true}}}Copy the code

If you change the state through this property alone, you are guaranteed not to forget to change a Boolean property and not to leave the page in an invalid state. Now it’s easy to change the page state.

self.state = .noDataCopy the code

The larger the number of possible states, the more useful this pattern becomes. You can even place both error messages and list data in enumerations by associating values.

enum State {
  case noData
  case loaded([Cell])
  case error(String)
}
var state: State = .noData {
  didSet {
    switch state {
    case .noData:
      noDataView.isHidden = false
      tableView.isHidden = true
      errorView.isHidden = true
    case .loaded(let cells):
      self.cells = cells
      noDataView.isHidden = true
      tableView.isHidden = false
      errorView.isHidden = true
    case .error(let error):
      errorView.errorLabel.text = error      
      noDataView.isHidden = true
      tableView.isHidden = true
      errorView.isHidden = false}}}Copy the code

At this point you have defined a single data structure that fully meets the data requirements of the entire Table View Controller. It is easy to test (because it is a pure Swift value) and provides a unique update entry and a unique data source for the Table View. Welcome to the new world of easy debugging!

Some Suggestions on

There are a few other tips that aren’t worth a separate section, but they’re still useful:

Responsive!

Make sure your Table View always shows the current state of the data source. Use a property observer to refresh the table View, don’t try to manually control the refresh.

var cells: [Cell] = [] {
  didSet {
    tableView.reloadData()
  }
}Copy the code

Delegate ! = View Controller

Any object and structure can implement a protocol! Keep this in mind the next time you write a complex table view data source or proxy. It is more efficient and preferable to define a type that is used exclusively as the data source for the Table View. This will keep your View Controller tidy, separating logic and responsibility into their respective objects.

Do not manipulate specific index values!

If you find yourself working with a particular index, using switch statements in groups to distinguish index values, or other similar logic, you probably did the wrong design. If you need a specific cell in a specific location, you should include it in the source data array. Do not manually hide these cells in your code.

Remember Demeter’s rule

In short, Demeter’s law (or least knowledge principle) states that in programming, an instance should talk only to its friends, not to friends of friends. Wait, what is this about?

In other words, an object should only access its own properties. Properties of its properties should not be accessed. Therefore, UITableViewDataSource should not set the text property of the cell’s label. If you see two dots in an expression (cell.label.text =…) “Is usually a sign that your object is accessing too deeply.

If you don’t follow Demeter’s rule, when you modify the cell you also have to modify the data source. Decoupling cells from data sources allows you to modify one without affecting the other.

Beware of false abstractions

Sometimes, multiple UITableViewCell classes that are close together are better than a cell class that contains a lot of if statements. You don’t know how they will diverge in the future, and abstracting them can be a design pitfall. YAGNI (you won’t need it) is a good principle, but sometimes you will achieve YJMNI (you just might need it).

Hopefully these tips will help you, and I’m sure you’ll make a table View next time. Here are some additional resources to help you read more:

  • Demeter’s rule
  • False abstraction
  • You don’t need it

If you have any questions or suggestions, feel free to leave them below.

Marin is an iOS developer, a blogger and a computer science student at COBE. He loves programming, learning things and writing them down, riding his bike and drinking coffee. Most of the time, he just crashed SourceKit. He has a fat cat named Amigo. He wrote the article hardly on his own.


The Nuggets Translation Project is a community that translates quality Internet technical articles from English sharing articles on nuggets. The content covers Android, iOS, React, front-end, back-end, product, design and other fields. If you want to see more high-quality translation, please continue to pay attention to the Project, official Weibo, Zhihu column.