Swift 101: Extracting TableView DataSource and Delegates from ViewControllers

Swift 101: Extracting TableView DataSource and Delegates from ViewControllers

ยท

5 min read

The topic of MVC (or Massive View Controllers as it is not so fondly called ๐Ÿ™ƒ) is one that every iOS developer has come across at one point or another. Starting out as a developer, MVC was the first pattern (or "architecture" ) that I learnt about. You know, just dumping everything inside your ViewController and forgetting about them since everything works fine.

The MVC (Model-View-Controller) pattern is the starting point for every iOS (UIKit) application and until the advent of SwiftUI (which advocates for an MVVM first approach), it was Apple's signature pattern. Over time, developers began creating and making use of other patterns all in a bid to avoid Massive View Controllers.

Several other architectures such MVVM (Model-View-ViewModel), VIPER (View,-Interactor-Presenter-Entity-Router), VIP (View-Interactor-Presenter), RIB (Router-Interactor-Builder) came up with the aim of extracting the business logic from the View Controllers and allowing Controllers to handle only the UI.

TableViews happen to be one of the most common components used in UIKit. Most times, it is used for displaying a list to users. Even while using the MVC pattern, we can create our tableviews elegantly in such a way that we don't have to worry about our controllers becoming too big or massive. Here's how:

When starting out as an iOS developer, I'd normally create my tableviews like this:

class ViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {

    var tableData = ["zero", "one", "two", "three", "four", "five", "six", "seven"]
    private let cellReuseIdentifier = "cell"

    private lazy var tableView: UITableView = {
        let tableView = UITableView()
        tableView.translatesAutoresizingMaskIntoConstraints = false
        return tableView
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        setupTable()
    }

    fileprivate func setupTable() {
        view.addSubview(tableView)

        NSLayoutConstraint.activate([
            tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            tableView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
            tableView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
            tableView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor)
        ])

        tableView.dataSource = self
        tableView.delegate = self
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: cellReuseIdentifier)
    }

 func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        tableData.count
    }

 func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseIdentifier, for: indexPath)
        cell.textLabel?.text = tableData[indexPath.row]
        return cell
    }

func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
        print("The index path selected is \(tableData[indexPath.row])")
    }

}

Along the line, I learnt that I could do better by making use of extensions to reduce the content of the ViewController itself. This does not reduce the load on the ViewController though because it is still responsible for setting up the tableView even if we put the extensions in a separate file. Using extensions, we will have:

class ViewController: UIViewController {

    var tableData = ["zero", "one", "two", "three", "four", "five", "six", "seven"]
    private let cellReuseIdentifier = "cell"

    private lazy var tableView: UITableView = {
        let tableView = UITableView()
        tableView.translatesAutoresizingMaskIntoConstraints = false
        return tableView
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        setupTable()
    }

    fileprivate func setupTable() {
        view.addSubview(tableView)

        NSLayoutConstraint.activate([
            tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            tableView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
            tableView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
            tableView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor)
        ])

        tableView.dataSource = self
        tableView.delegate = self
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: cellReuseIdentifier)
    }

}

extension ViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        tableData.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseIdentifier, for: indexPath)
        cell.textLabel?.text = tableData[indexPath.row]
        return cell
    }
}


extension ViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
        print("The index path selected is \(tableData[indexPath.row])")
    }
}

Extracting the tableView dataSource and delegate

After identifying a problem, the next thing will be providing a solution. To solve this issue, the first thing will be to create a new class, let's call it MyTableViewDataSource and have this class conform to NSObject, UITableViewDataSource and UITableViewDelegate. We also move our tableData into this new class. To avoid repeating the cellReuseIdentifier, I have created an AppConstants file to take care of that.

Next step will be to create an instance of the the dataSource in our ViewController and set the dataSource and delegate of the tableView to be this dataSource. With this, everything should work fine.

Here is the end result:

AppConstants file (other constants can be added later):

struct AppConstants {
    static let cellReuseIdentifier = "cell"
}

MyTableViewDataSource file:

class MyTableViewDataSource: NSObject, UITableViewDataSource, UITableViewDelegate {
    fileprivate let tableData = ["zero", "one", "two", "three", "four", "five", "six", "seven"]

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        tableData.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: AppConstants.cellReuseIdentifier, for: indexPath)
        cell.textLabel?.text = tableData[indexPath.row]
        return cell
    }

    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
        print("The index path selected is \(tableData[indexPath.row])")
    }
}

The View Controller file:

class ViewController: UIViewController {

    private let dataSource = MyTableViewDataSource()

    private lazy var tableView: UITableView = {
        let tableView = UITableView()
        tableView.translatesAutoresizingMaskIntoConstraints = false
        return tableView
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        setupTable()
    }

    fileprivate func setupTable() {
        view.addSubview(tableView)

        NSLayoutConstraint.activate([
            tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            tableView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
            tableView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
            tableView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor)
        ])

        tableView.dataSource = dataSource
        tableView.delegate = dataSource

        tableView.register(UITableViewCell.self, forCellReuseIdentifier: AppConstants.cellReuseIdentifier)
    }
}

With this implementation, we have successfully extracted the responsibility of setting up the table away from the ViewController and given it to another file. This has both reduced the content of the ViewController and its responsibility.

So, next time you are implementing a tableView, using a separate dataSource can help make your code look cleaner and help in separation of concerns. After all, we are all advocates of clean code.

Footnote:

This same implementation is applicable to UICollectionView while trying to declutter the ViewController. This particular implementation can be improved upon by making use of other architectural patterns like the ones listed above.

As always, this is me running after Swift, until it decides to wait for me.

Till next time, may the code always be with you.