Best practices for configuring the table view cells on iOS with Swift

Best practices for configuring the table view cells on iOS with Swift

Many times, iOS developers utilize UITableView and UITableViewCell subclasses and sometimes UICollectionView variants to create beautiful views. But it could be tricky when you're writing it for the first time. It's important to know the best practices on how to configure these cells. In this post, I am going to talk about some vital points you need to keep in mind as well as bonus tips in relation to cell reusability.

Configure Cells inside UITableViewCell subclass

Never configure cells inside the cell's parent view controller. It bloats the controller and you will run into a massive view controller problem. Have the code to configure cell inside the cell subclass or even better, inside custom cell configurator which takes cell and model as the input and returns the configured cell (which I am going to talk about at the end of this article). Cell only has the code to layout its views, so it's not a bad practice to have a little bit of code to configure its content based on the model properties.

Another advantage of putting configuration inside the cell is, each iOS cell should override a superclass method called prepareForReuse(). This method gets called every time the cell is reused. You can put the code to reset the cell's state inside it (Cancelling network requests, clearing out label content, etc.) so that cell doesn't end up with a non-visible cell's state as cells are reused.

Below is an example of a sample cell and let's see how it is configured,

First, the ViewController will dequeue the cell from tableView and call configure method on the cell passing the model applicable for the current row.

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
	guard let cell = tableView.dequeueReusableCell(withIdentifier: MyTableViewCell.identifier, for: indexPath) as? MyTableViewCell else {
        fatalError("Failed to get expected kind of reusable cell from the tableView. Expected type `MyTableViewCell`")
    }
    cell.configure(model: self.models[indexPath.row])
    return cell
}
final class MyTableViewCell: UITableViewCell {
	let logoImageView: UIImageView = ...
	let nameLabel: UILabel = ...
    
    // Initializer
	override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
		.......
		...
	}
    
	func configure(model: MyModel) {
		self.nameLabel.text = model.name
		self.logoImageView.image = UIImage(named: model.imageName)
	}
}

By doing so, we have modularized the cell configuration code in one place. In the future, when we need to change it, we don't have to spend our time trying to figure out how it's done in the ViewController which may have other responsibilities.

It also makes it easy to write unit tests. If you want to make sure configure function is not broken, you can simply test the code inside by calling this method and passing MyModel instance with dummy data. That way, you don't even need to worry about ViewController subclass or mocking it out for the sake of testing code concerned with cell subclass.

Clearing out cell state before reuse

Unless there are exact number of cells that fit into the current screen size, iOS reuses cells. Every time the cell goes out of view, it will be reused for the next cell appearing on the screen but configured with a different model thus changing its content and possibly layout.

While the cell is being reused, we must clear its previous state. If you're updating cell content in the configure method above every time cell appears on the screen, this problem will not exist, but if you are updating the cell's content conditionally, it may retain its state which will be used next time the cell is used.

For example, let's look at this code inside the same configure method owned by MyTableViewCell

final class MyTableViewCell: UITableViewCell {
	let logoImageView: UIImageView = ...
	let nameLabel: UILabel = ...
    
	func configure(model: MyModel) {
        if model.toShowName {
        	self.nameLabel.text = model.name	
        }	
		self.logoImageView.image = UIImage(named: model.imageName)
	}
}

In this example, we are conditionally setting the nameLabel based on the value of toShowName property on MyModel. If toShowName property is true for cell number 0, we will set it up with the model's name property. If this cell is reused for cell number 10 for which toShowName property is false, the nameLabel won't be updated and it will keep using the same name value of model.name which was used for cell number 0 - clearly a bug.

This problem can be solved in two ways,

Clearing out the nameLabel's text in configure method

	func configure(model: MyModel) {
        if model.toShowName {
        	self.nameLabel.text = model.name	
        } else {
        	self.nameLabel.text = nil
        }
		
		self.logoImageView.image = UIImage(named: model.imageName)
	}

Using prepareForReuse method to clear the label,

override func prepareForReuse() {
        super.prepareForReuse()
       	self.nameLabel.text = nil
  }

Canceling the image download operation in the prepareForReuse method

There is another use-case where you might have sent a request to download the image inside configure method, but before download finishes, the cell has been reused. In that case, you can cancel the download for the current cell inside prepareForReuse method to preserve resources and make room for the next download operation.

	func configure(model: MyModel) {
		...
		
		self.profileImageView.foo_setImage(from: model.profilePicURL, placeholderImage: UIImage(named: "placeholder"))
	}
    
    override func prepareForReuse() {
        super.prepareForReuse()
    	self.profileImageView.foo_cancelImageDownload()
    }

Handling async operations in reusable views

Reusable view and async operations have gotchas of their own. but how about the situation when they both come together? When you're doing an async operation in reusable views, a lot of things can go wrong.

Consider an example with image download operation. Given the image URL, you start calling your image downloader utility which runs in async mode on the background thread. As soon as the image download operation finishes, it returns the UIImage object in closure.

class ImageDownloader {

	static let shared: ImageDownloader = ...
	func download(with urlPath: String, completion: ((String, UIImage) -> Void)) {
    	let image: UIImage = ...
        DispatchQueue.main.async {
        	completion(urlPath, image)
        }     	
    }
}


class MyTableViewCell: UITableViewCell {
	func configure(model: MyModel) {
		...
        let inputURLPath = model.logoURLPath
        ImageDownloader.shared.download(with: inputURLPath, completion: { (outputURLPath, image) in
            cell.logoImageView.image = image
        })
    }
}

What if the current cell has been re-used in the meantime? You start downloading image0 for cell number 0. By the time download completes, that cell has been reused for cell number 10 but the image being returned belongs to cell number 0 and will be attached to cell number 10 for some time before image download operation for image10 finishes. But it's still is a bad user experience and if the image download for cell number 10 finishes with an error, the user will end up seeing the image0 for cell number 10 which is again wrong.

How can you fix this problem?

You can fix the problem by comparing the input URL that you previously passed to download the image and the output URL provided by the closure once the download operation completes. If the closure returns while the same cell is visible on the screen, it will attach it to imageView since both input and output URLs will be the same. If the cell has been reused, input and output URLs will not match and it will show the default image instead.

class MyTableViewCell: UITableViewCell {
	func configure(model: MyModel) {
		...
        let inputURLPath = model.logoURLPath
        ImageDownloader.shared.download(with: inputURLPath, completion: { (outputURLPath, image) in
        if inputURLPath == outputURLPath {
        	cell.logoImageView.image = image
        } else {
        	cell.logoImageView.image = UIImage(named: "default_image")
        }
            
        })
    }
}

Using the cell Configurator to configure cell's content

Here is another idea for configuring cells - Using CellConfigurator. you can create any number of CellConfigurtor classes each having its own implementation of how it wants to configure cell given the cell and model. With a custom cell configurator, you can even mock the implementation during unit testing.

If you need to configure a cell, based on A/B testing, you may pass a custom cell configurator based on A/B testing evaluation. These configurators are passed by protocol types so you don't need to worry about underlying concrete types at the runtime.

For example, let's take a look at cell configurators conforming to CellConfigurable protocol but having different implementations.

protocol CellConfigurable {
	func configure(cell: MyTableViewCell, model: MyModel)
}

class CellConfiguratorOne: CellConfigurable {
	func configure(cell: MyTableViewCell, model: MyModel) {
    	cell.textLabel.text = "One"
    }
}

class CellConfiguratorTwo: CellConfigurable {
	func configure(cell: MyTableViewCell, model: MyModel) {
	    cell.textLabel.text = "Two"  
    }
}

// My View Controller
class MyViewController: UIViewController {
 	private let configurator: CellConfigurable
	init(abTestingManager: ABTestingManager) {
    	configurator = abTestingManager.isTypeOne ? CellConfiguratorOne() : CellConfiguratorTwo()
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    	let cell = ...
        let model = self.models[indexPath.row]
        configurator.configure(cell: cell, model: model)
        return cell
    }
}

During runtime, based on the result of A/B testing, we will pass one configurator or another. If in this case, you need to create the third configuration, simply create another class conforming to CellConfigurable, and call configure method based on the result of evaluating ABTesting manager.

That's all folks for today. I have learned all these tips from developing iOS applications for years and making lots of mistakes I have mentioned here. Hopefully, you will find them useful. If you think I have missed anything or this article needs improvement, please feel free to reach out on Twitter @jayeshkawli.