Always handle your table & collection view cell selection properly, using functions
When writing iOS, you will always end up using UITableView
or UICollectionView
.
With them come cells, delegates, datasources, cellForRow(at indexPath:), didSelect,...
I've too often seen it become (and inherited) an ugly mess. Huge files, complex cell setup and cell selection handling functions, with huge switches and setting up all of the cells' properties in the ViewController. I could go on and on all day, but suffice to say I can't wait to check out how SwiftUI does it.
Today, we'll be tackling the cell selection mess. The one where you have to store your cell's content in your ViewController, fetch it using your cell's indexPath, sometimes crash because of a mismatch, and perhaps even switch based on the cell's type.
We won't be doing any of that. Remember how we managed to delay our ViewControllers' setup until the viewDidLoad()
call? We'll be doing something similar, but with cells.
Implementation
Our story starts with a simple UIViewController
and it's UITableView
They are getting along fine, but the ViewController would like to know what to do when a user tap's on the TableView's rows.
import UIKit
import PlaygroundSupport
class MyViewController: UIViewController {
@IBOutlet weak var tableView: UITableView!
private var items: [String] = []
private var delayedSetup: (() -> ())? = nil
override func viewDidLoad() {
self.delayedSetup?()
}
// Here our items are a String array, but it could just as well be a String array observable we subscribe to, to set and continuously update our items
private func setup(items: [String]) {
self.items = items
self.tableView.delegate = self
self.tableView.dataSource = self
}
public func setup(with items: [String]) {
self.delayedSetup = { [weak self] in
return self?.setup(items: items)
}
}
}
extension MyViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return self.items.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: "MyCell")) as? MyCell else {
return MyCell()
}
return cell
}
}
extension MyViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
}
}
class MyCell: UITableViewCell, Tappable {
@IBOutlet weak var label: UILabel!
internal func setup(model: String) {
self.label.text = model
}
}
Our cell meets a new friend. The Tappable protocol, which has an onTap()
optional function property, and the cell finds it so great it decides to conform itself to it. And of course, the cell remembers to set it's new property to nil on her prepareForReuse
call (because it doesn't cost much, and you never know how your cell will be used next month)
import UIKit
import PlaygroundSupport
// We use a protocol so any and all of our cells can conform to it. No need to figure out which type to cast our cell to in didSelectRowAt anymore!
protocol Tappable {
var onTap: (() -> ())? { get set }
}
...
extension MyViewController: UITableViewDataSource {
...
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: MyCell.self)) as? MyCell else {
return MyCell()
}
return cell
}
}
...
class MyCell: UITableViewCell, Tappable {
@IBOutlet weak var label: UILabel!
var onTap: (() -> ())? = nil
internal func setup(model: String) {
self.label.text = model
}
override func prepareForReuse() {
super.prepareForReuse()
self.onTap = nil
}
}
What happens next? Why, our brave cell introduces it's favorite ViewController to it's new bestie of course! And the ViewController, blown away, immediately takes advantage of the opportunity to make communication simpler and clearer between them, setting up the cell's onTap
property in it's cellForRowAt indexPath()
delegate method, with wathever it would like to do when a user taps on the tableView's cells.
...
extension MyViewController: UITableViewDataSource {
...
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: MyCell.self)) as? MyCell else {
// I have yet to figure out what I should do here
return MyCell()
}
cell.onTap = { [weak self] in
return { (indexPath: IndexPath, model: String?) in
print("Tapped on cell at row \(indexPath.row) in section \(indexPath.section), with model: \(model ?? "undefined")")
}(indexPath, self?.items[indexPath.row])
}
return cell
}
}
...
Finally, when a user does tap on the cell, the delegate's didSelectRowAt
method is triggered, and we get the cell from there, as a Tappable
, using the indexPath (yes, I got tired of trying to turn all this into a story).
...
extension MyViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
(tableView.cellForRow(at: indexPath) as? Tappable)?.onTap
}
}
...
The End
And it's done! I hope you like it, and it simplifies some of your existing and future work the same way it did for me! If you have any questions or remarks (like, what should I on a failed dequeue), hit me up on Twitter! Here's the full code, along with a nice little bonus for those of you still hard coding your cell identifiers
import UIKit
import PlaygroundSupport
// We use a protocol so any and all of our cells can conform to it. No need to figure out which type to cast our cell to in didSelectRowAt anymore!
protocol Tappable {
var onTap: (() -> ())? { get set }
}
class MyViewController: UIViewController {
@IBOutlet weak var tableView: UITableView!
private var items: [String] = []
private var delayedSetup: (() -> ())? = nil
override func viewDidLoad() {
self.delayedSetup?()
}
// Here our items are a String array, but it could just as well be a String array observable we subscribe to, to set and continuously update our items
private func setup(items: [String]) {
self.items = items
self.tableView.delegate = self
self.tableView.dataSource = self
}
public func setup(with items: [String]) {
self.delayedSetup = { [weak self] in
return self?.setup(items: items)
}
}
}
extension MyViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return self.items.count
}
// One of the many things that changed my life: instead of playing around with hard coded string identifiers, use the cell's class name itself as the cell's identifier!
// No need to double-check for spelling errors anymore!
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: MyCell.self)) as? MyCell else {
// I have yet to figure out what I should do here
return MyCell()
}
cell.onTap = { [weak self] in
return { (indexPath: IndexPath, model: String?) in
print("Tapped on cell at row \(indexPath.row) in section \(indexPath.section), with model: \(model ?? "undefined")")
}(indexPath, self?.items[indexPath.row])
}
return cell
}
}
extension MyViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
(tableView.cellForRow(at: indexPath) as? Tappable)?.onTap
}
}
class MyCell: UITableViewCell, Tappable {
@IBOutlet weak var label: UILabel!
var onTap: (() -> ())? = nil
internal func setup(model: String) {
self.label.text = model
}
override func prepareForReuse() {
super.prepareForReuse()
self.onTap = nil
}
}