Architecture Swift

Build a Table View with multiple cell types: Multiple MVCs technique.

Hello everyone. Nice to see you in another post of mine. Today I want to show you a simple technique that you can use when you’re dealing with table view or collection view has multiple cell types to increase your code quality. OK let’s get started.

Case study

Imagine you’re building a feed showing a list of cars that you want to sell. UI of the screen looks like bellow:

The challenge is the screen needs display not just production cells but also advertising cells which have different layout from production cell type. Stop here for a moment, let’s try to find a solution before you continue.

Concrete solution

A common solution I’ve seen in many codebases is let the feed view controller decide which type of cell to use by using if-else statement inside the view controller like this:

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
   if model.advertising {
      let cell = tableView.dequeueReusableCell(withIdentifier: "AdvertisingCell", for: indexPath) as! AdvertisingCell
      // configure advertising cell
   } else {
      let cell = tableView.dequeueReusableCell(withIdentifier: "ProductImageCell", for: indexPath) as! ProductImageCell
      // configure production cell
   }
}

The solution works absolutely fine. But what are problems with this approach ? Let’s list a few:

  1. Extensibility: What if I have more cell types in the future such as video cell or live streaming cell ? One way to do that is I have to add more if-else statement inside the function. And the more if-else I add to the function the more complex the view controller becomes. Put another way, It against the extensibility.
  2. Repetitive: Make the case that different cell types will behave differently on user selection, advertising cell selection will navigate user to a website and production cell selection will navigate user to a detail screen for example. So that we need to make exactly the same if-else we did in the view controller above in didSelectRowAt method like so:
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    if model.advertising {
       // Navigate to safari
   } else {
      // Navigate to detail screen
   }
}

Your code will soon become boilerplate. One more case added will make us change in multiple places.

Take a look at the implementation of the feed view controller so far:

import UIKit

class FeedViewController: UITableViewController {
    var items = [FeedItem]() {
        didSet {
            tableView.reloadData()
        }
    }
    
    var loader: FeedLoader?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        loader?.load {
            self.items = $0
        }
        
        tableView.register(UINib(nibName: "ProductImageCell", bundle: nil), forCellReuseIdentifier: "ProductImageCell")
        tableView.register(UINib(nibName: "AdvertisingCell", bundle: nil), forCellReuseIdentifier: "AdvertisingCell")
        
        tableView.reloadData()
    }
    
    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        items.count
    }
    
    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let model = items[indexPath.row]
        if model.advertising {
            let cell = tableView.dequeueReusableCell(withIdentifier: "AdvertisingCell", for: indexPath) as! AdvertisingCell
            cell.nameLabel.text = model.name
            cell.operatedByLabel.text = "Operated by \(model.operationBy)"
            cell.priceLabel.text = "$\(model.price)"
            cell.productImageView.image = UIImage(named: model.image)
            return cell
        } else {
            let cell = tableView.dequeueReusableCell(withIdentifier: "ProductImageCell", for: indexPath) as! ProductImageCell
            cell.nameLabel.text = model.name
            cell.operatedByLabel.text = "Operated by \(model.operationBy)"
            cell.priceLabel.text = "$\(model.price)"
            cell.productImageView.image = UIImage(named: model.image)
            return cell
        }
    }
    
    override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        return 89
    }
    
}

If you also using this solution, don’t worry you’re not the only one but there must be a better way, we should move all the if-else statements from the feed view controller. Well, so what are other solutions ? Someone will use other UI patterns such as MVVM or MVP but in this simple case, you don’t have to. You can keep using MVC, but not the way we did above. OK, let’s figure out in the next section.

Before jump into next section, you can download the starter project where I’ve already implemented the concrete solution as above:

https://github.com/LearnWithTung/MultipleCellsTableView/tree/master/starter

Multiple MVCs

The idea behind this approach is simple. Instead of just using one MVC per screen you can separate it into many small MVCs. That is, each table view cell can be a MVC !

So that we can move the responsibility of managing cells from the table view controller to cell controllers and the view controller just renders what it given to. So let’s implement it.

We have two cell types in this case so I’ll create two classes responsible for managing cell model.

class ProductCellController {
    private let model: FeedItem
    
    init(model: FeedItem) {
        self.model = model
    }
    
    func view(in tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "ProductImageCell", for: indexPath) as! ProductImageCell
        cell.nameLabel.text = model.name
        cell.operatedByLabel.text = "Operated by \(model.operationBy)"
        cell.priceLabel.text = "$\(model.price)"
        cell.productImageView.image = UIImage(named: model.image)
        
        return cell
    }
}

class AdvertisingCellController {
    private let model: FeedItem
    
    init(model: FeedItem) {
        self.model = model
    }
    
    func view(in tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "AdvertisingCell", for: indexPath) as! AdvertisingCell
        cell.nameLabel.text = model.name
        cell.operatedByLabel.text = "Operated by \(model.operationBy)"
        cell.priceLabel.text = "$\(model.price)"
        cell.productImageView.image = UIImage(named: model.image)
        
        return cell
    }
}

As you can see, we moved the responsibility of managing from the feed view controller to the view(in tableView:, at indexPath:) method, it will handle creating and configuring cell and then returns that cell back to the feed view controller.

In order to facilitate extensibility I create an interface that all CellControllers need conform to.

protocol FeedCellController {
    func view(in tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell
}

class ProductCellController: FeedCellController {...}
class AdvertisingCellController: FeedCellController {...}

And the FeedViewController don’t need to decide which cell to create anymore which is much simple like this:

 override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cellController = cellControllers[indexPath.row]
        
        return cellController.view(in: tableView, at: indexPath)
    }

And of course In the feed view controller, instead of holding an array of FeedItem it now holds an array of FeedCellController and to make the complier complies we need to map the [FeedItem] fetched from the loader to [FeedCellController] like so :

loader?.load { items in
            self.cellControllers = items.map {
                if $0.advertising {
                    return AdvertisingCellController(model: $0)
                } else {
                    return ProductCellController(model: $0)
                }
            }
        }

Finally, here’s the new implementation of the FeedViewController:

class FeedViewController: UITableViewController {
    var cellControllers = [FeedCellController]() {
        didSet {
            tableView.reloadData()
        }
    }
    
    var loader: FeedLoader?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        loader?.load { items in
            self.cellControllers = items.map {
                if $0.advertising {
                    return AdvertisingCellController(model: $0)
                } else {
                    return ProductCellController(model: $0)
                }
            }
        }
        
        tableView.register(UINib(nibName: "ProductImageCell", bundle: nil), forCellReuseIdentifier: "ProductImageCell")
        tableView.register(UINib(nibName: "AdvertisingCell", bundle: nil), forCellReuseIdentifier: "AdvertisingCell")
        
        tableView.reloadData()
    }
    
    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        cellControllers.count
    }
    
    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cellController = cellControllers [indexPath.row]
        
        return cellController.view(in: tableView, at: indexPath)
    }
    
    override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        return 89
    }
    
}

We are done ! The FeedViewController is now much simpler, isn’t it ? In the future if we need more cell types, we just need to create new cell controllers class to handle that case and the FeedViewController still keeps clean. And if you need to handle selection action, you can also apply this technique, it’s very similar.


Conclusion

Alright, this is the end of the article. Thank you for reading. If you like it please share and comment down bellow to support me.

You can download the complete code here:

https://github.com/LearnWithTung/MultipleCellsTableView/tree/master/complete

I hope you got the idea, this technique not only can apply for table view but collection view, and much more than that as well, again don’t forget to practice on your own. See you in the next post. Goodbye !

Trả lời

Email của bạn sẽ không được hiển thị công khai. Các trường bắt buộc được đánh dấu *