Handling cell actions with Swift generics

Profile photo of Igor Nemenonok
Igor Nemenonok
Nov 06, 2017
4 min
Categories: Development
bookshelf
bookshelf

Give iOS developer a table view and he will write 10+ implementations of how to handle cell actions.

As we are dealing with tables and collections quite often in Chili Labs, I wanted to make a generic solution how we are going to handle cell actions in our projects.

The solution with cell configurators that was described in my previous article works pretty well. Thus it could be a good idea to take it as a base pattern.

First of all, let’s add TableDirector class that will store cell configurators and will be a delegate and a data source for UITableView.

import UIKit class TableDirector: NSObject { let tableView: UITableView //reload table after each items update private(set) var items = [CellConfigurator]() { didSet { self.tableView.reloadData() } } //init with initial items init(tableView: UITableView, items: [CellConfigurator]) { self.tableView = tableView super.init() self.tableView.delegate = self self.tableView.dataSource = self self.items = items } } extension TableDirector: UITableViewDelegate, UITableViewDataSource { //one section only if we have at least one item func numberOfSections(in tableView: UITableView) -> Int { return self.items.count > 0 ? 1 : 0 } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return self.items.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cellConfigurator = self.items[indexPath.row] let cell = tableView.dequeueReusableCell(withIdentifier: type(of: cellConfigurator).reuseId, for: indexPath) cellConfigurator.configure(cell: cell) return cell } /* Cell actions */ func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { //will be handled later } func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { //will be handled later } }

Let's make an enum for some base cell actions. It has to conform to Hashable protocol as its hash value will be used later.

enum CellAction: Hashable { case didSelect case willDisplay var hashValue: Int { switch self { case .didSelect: return 0 case .willDisplay: return 1 } } static func ==(lhs: CellAction, rhs: CellAction) -> Bool { return lhs.hashValue == rhs.hashValue } }

Next, we need to create a class that will proxy cell actions and will store cell actions handlers. Right here we need *CellAction’s *hashValue as a part of a storage key.

class CellActionProxy { //storage where subscribers' closures are stored private var actions = [String: ((CellConfigurator, UIView) -> Void)]() //method to invoke cell action and notify all subscribers func invoke(action: CellAction, cell: UIView, configurator: CellConfigurator) { let key = "\(action.hashValue)\(type(of: configurator).reuseId)" if let action = self.actions[key] { action(configurator, cell) } } //subscribe to cell action. returning self to chain subscriptions @discardableResult func on<CellType, DataType>(_ action: CellAction, handler: @escaping ((TableCellConfigurator<CellType, DataType>, CellType) -> Void)) -> Self { let key = "\(action.hashValue)\(CellType.reuseIdentifier)" self.actions[key] = { (c, cell) in handler(c as! TableCellConfigurator<CellType, DataType>, cell as! CellType) } return self } }

We need to implement static property reuseIdentifier in ConfigurableCel as it's used as a part of a key for actions dictionary.

protocol ConfigurableCell { static var reuseIdentifier: String { get } // ... } extension ConfigurableCell { static var reuseIdentifier: String { return String(describing: Self.self) } } //now we can use the same reuseIdentifier in TableCellConfigurator implmentation class TableCellConfigurator<CellType: ConfigurableCell, DataType: Hashable>: CellConfigurator where CellType.DataType == DataType, CellType: UITableViewCell { static var reuseId: String { return CellType.reuseIdentifier } // ... }

It’s time to invoke first cell actions in TableDirector class.

//defining actionProxy variable in TableDirector class let actionsProxy = CellActionProxy() //invoking actions func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { //no need to keep cell selected tableView.deselectRow(at: indexPath, animated: true) let cellConfigurator = self.items[indexPath.row] guard let cell = tableView.cellForRow(at: indexPath) else { return } self.actionsProxy.invoke(action: .didSelect, cell: cell, configurator: cellConfigurator) } func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { let cellConfigurator = self.items[indexPath.row] self.actionsProxy.invoke(action: .willDisplay, cell: cell, configurator: cellConfigurator) }

The initial implementation is done. Now we can add handlers for cell actions. TableViewController class can be edited as follows:

class TableViewController: UITableViewController { internal let viewModel = TableViewModel() lazy private var tableDirector: TableDirector = { return TableDirector(tableView: self.tableView, items: self.viewModel.items) }() override func viewDidLoad() { super.viewDidLoad() self.tableView.rowHeight = UITableViewAutomaticDimension self.tableView.estimatedRowHeight = 50 self.tableView.tableFooterView = UIView() self.addHandlers() } private func addHandlers() { self.tableDirector.actionsProxy.on(.didSelect) { (c: UserCellConfig, cell) in print("did select user cell", c.item, cell) }.on(.willDisplay) { (c: UserCellConfig, cell) in print("will display user cell", c.item, cell) }.on(.didSelect) { (c: ImageCellConfig, cell) in print("did select image cell", c.item, cell) } } }

Subscription to cell actions takes place in addHandlers() method.

What are the benefits from handling cell actions like that?
  • All actions processing are in one place.
  • Configurator, data model and particular cell are available in closure.
  • No if statements or switches. You can add a handler for particular cell action.
  • Reusable solution.

Custom cell actions

The solution doesn’t make sense if we can’t process custom cell actions. For example, you need to add “Follow” button as a part of user cell. So, now we must handle two actions for one cell, and one of them is triggered by a button within the cell.

https://miro.medium.com/max/930/1*UCsIliG-RHc9Wf2dX7u__A.png

First, we need to add one more case to CellAction enum and return its hashValue.

enum CellAction: Hashable { // ... case custom(String) public var hashValue: Int { switch self { // ... case .custom(let custom): return custom.hashValue } } // ... }

The most challenging thing here is how to pass custom action to the CellActionProxy object. It can be done by sending Notification via NotificationCenter. We can add an extension to CellAction enum for the convenient way of sending notifications. Action data will be stored in a struct and sent in the userInfo parameter.

//notification data struct CellActionEventData { let action: CellAction let cell: UIView } extension CellAction { static let notificationName = NSNotification.Name(rawValue: "CellAction") public func invoke(cell: UIView) { NotificationCenter.default.post(name: CellAction.notificationName, object: nil, userInfo: ["data": CellActionEventData(action: self, cell: cell)]) } }

Finally, we need to subscribe and process notification in TableDirector class.

// TableDirector.swift init(tableView: UITableView, items: [CellConfigurator]) { // ... NotificationCenter.default.addObserver(self, selector: #selector(onActionEvent(n:)), name: CellAction.notificationName, object: nil) } @objc fileprivate func onActionEvent(n: Notification) { if let eventData = n.userInfo?["data"] as? CellActionEventData, let cell = eventData.cell as? UITableViewCell, let indexPath = self.tableView.indexPath(for: cell) { actionsProxy.invoke(action: eventData.action, cell: cell, configurator: self.items[indexPath.row]) } } deinit { NotificationCenter.default.removeObserver(self) }

Now we can invoke “Follow” action and handle it.

//UserCell.swift @IBAction func onFollowTap(_ sender: Any) { CellAction.custom(type(of: self).userFollowAction).invoke(cell: self) } //TableViewController.swift tableDirector.actionsProxy.on(.custom(UserCell.userFollowAction)) { (c: UserCellConfig, cell) in print("follow user", c.item) }

That’s it! Now we have a mechanism to handle any cell action generically. With tiny modifications it can be used to handle UICollectionViewCell actions.

Bonus: Animated change of data source

Usually, data in the app is not static and can be changed over the time (e.g., after an update from the server). Reloading a table view after each data source change is not a good idea. UITableView has a built-in API for data source change animation, and it works really good. When data source changes we need to find how many rows were added, deleted, moved or reloaded. But how to do that?

There is a good Swift port of IGListKit’s IGListDiff that will help us to find those changes in the data source.

First, we need to add the hash variable to CellConfigurator protocol.

protocol CellConfigurator { var hash: Int { get } ... }

Now we need to implement that hash variable in TableCellConfigurator class. Here we need to provide unique hash that depends on CellType and DataType. We can use hashValue of Hashable protocol, but to do that we need to add one more constraint to DataType.

class TableCellConfigurator<CellType: ConfigurableCell, DataType: Hashable>

And now we can add the implementation:

var hash: Int { return String(describing: CellType.self).hashValue ^ item.hashValue }

Next, we need to conform User struct to Hashable protocol. String and URL already support Hashable protocol.

extension User: Hashable { static func ==(lhs: User, rhs: User) -> Bool { return lhs.hashValue == rhs.hashValue } var hashValue: Int { return name.hashValue ^ imageName.hashValue } }

The last thing to do is to animate changes in the data source.

//TablDirector.swift private(set) var items = [CellConfigurator]() { didSet { if oldValue.isEmpty { self.tableView.reloadData() } else { let oldHashes = oldValue.map { $0.hash } let newHashes = items.map { $0.hash } let result = DiffList.diffing(oldArray: oldHashes, newArray: newHashes) self.tableView.perform(result: result) } } } extension UITableView { func perform(result: DiffList.Result) { if result.hasChanges { self.beginUpdates() if !result.deletes.isEmpty { self.deleteRows(at: result.deletes.flatMap { IndexPath(row: $0, section: 0) }, with: .automatic) } if !result.inserts.isEmpty { self.insertRows(at: result.inserts.flatMap { IndexPath(row: $0, section: 0) }, with: .automatic) } if !result.updates.isEmpty { self.reloadRows(at: result.updates.flatMap { IndexPath(row: $0, section: 0) }, with: .automatic) } if !result.moves.isEmpty { result.moves.forEach({ (index) in let toIndexPath = IndexPath(row: index.to, section: 0) self.moveRow(at: IndexPath(row: index.from, section: 0), to: toIndexPath) }) } self.endUpdates() } } }

And voila, data changes with beautiful animation.

https://miro.medium.com/max/952/1*4sUoTJXxZdZOdho7I8tWOw.gif

Hope this article will help you in your projects.

Complete source code can be found on Github (branch cellActions).

Thanks for reading!

https://miro.medium.com/max/300/1*hB1OofoTpqI6wm43oKjH8A.png

Receive a new Mobile development related stories first. — Hit that follow button

Twitter: @ ChiliLabs

www.chililabs.io

Share with friends

Let’s build products together!

Contact us