Table of contents :
Say you have a button inside every cell in a tableview like this :
You want the app to show an alert with message "Subscribed to [youtuber name]" when user tap on the subscribe button. How do you implement this function? You would need to keep track of which cell got tapped and use that index to find the respective youtuber name from an array.
Let's check out the top result of searching 'button click in uitableviewcell' in google:
The answer was suggesting to use the tag attribute on the button :
let youtubers = ["Brian Voong", "Seth Everman", "Dave Lee", "Cybershell", "Bill Wurtz"]
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseIdentifier, for: indexPath) as! YoutuberTableViewCell
cell.youtuberLabel.text = youtubers[indexPath.row]
// assign the index of the youtuber to button tag
cell.subscribeButton.tag = indexPath.row
// call the subscribeTapped method when tapped
cell.subscribeButton.addTarget(self, action: #selector(subscribeTapped(_:)), for: .touchUpInside)
return cell
}
@objc func subscribeTapped(_ sender: UIButton){
// use the tag of button as index
let youtuber = youtubers[sender.tag]
let alert = UIAlertController(title: "Subscribed!", message: "Subscribed to \(youtuber)", preferredStyle: .alert)
let okAction = UIAlertAction(title: "OK", style: .default, handler: nil)
alert.addAction(okAction)
self.present(alert, animated: true, completion: nil)
}
Why not to use tag?
This approach works at first glance but I feel like it is a workaround. Tag property is original intended to use to uniquely identify a view in app (similar to creating an IBOutlet to identify a view, eg: self.view.viewWithTag(42) ), not to use as a data store (in this case, storing the row / index of the item). Abusing tag can quickly lead to a nightmare like this if you have multiple section :
cell.likeButton.tag = indexPath.row + 100
...
// I have seen production code which uses tag 101, 102, 103 etc as row in first section; 201, 202, 203 etc as row in second section..
self.tableView.scrollToRow(at: IndexPath(row: sender.tag, section: sender.tag / 100), at: .top, animated: true)
How would I know what is the /100 is for?! Shouldn't we use something more intuitive than this math calculation? What if there is more than 100 row in a section? use 1000?!
The stack overflow post has linked to another post for handling tableviews with multiple sections, and it involve detecting the coordinate of the cell tapped to deduce the index, oh god why đ±.
Passing the index data shouldn't be that difficult! There are multiple ways to go about this, this post will cover using delegate and closure to handle button click inside a tableview cell.
The Delegate way
You can read how delegate works here if you are not farmiliar with it yet. To use the delegate way, we will add a index integer property (to keep track of the index) and delegate property to the cell class.
//YoutuberTableViewCell.swift
class YoutuberTableViewCell: UITableViewCell {
@IBOutlet weak var youtuberLabel: UILabel!
@IBOutlet weak var subscribeButton: UIButton!
// the youtuber (Model), you can use your custom model class here
var youtuber : String?
// the delegate, remember to set to weak to prevent cycles
weak var delegate : YoutuberTableViewCellDelegate?
override func awakeFromNib() {
super.awakeFromNib()
// Initialization code
// Add action to perform when the button is tapped
self.subscribeButton.addTarget(self, action: #selector(subscribeButtonTapped(_:)), for: .touchUpInside)
}
override func setSelected(_ selected: Bool, animated: Bool) {
super.setSelected(selected, animated: animated)
// Configure the view for the selected state
}
@IBAction func subscribeButtonTapped(_ sender: UIButton){
// ask the delegate (in most case, its the view controller) to
// call the function 'subscribeButtonTappedFor' on itself.
if let youtuber = youtuber,
let delegate = delegate {
self.delegate?.youtuberTableViewCell(self, subscribeButtonTappedFor: youtuber)
}
}
}
// Only class object can conform to this protocol (struct/enum can't)
protocol YoutuberTableViewCellDelegate: AnyObject {
func youtuberTableViewCell(_ youtuberTableViewCell: YoutuberTableViewCell, subscribeButtonTappedFor youtuber: String)
}
The delegate is any object which conform to the YoutuberTableViewCellDelegate protocol, which means the object has to implement the subscribeButtonTappedFor function to be able to be the delegate.
In the Tableview data source, we will assign the index and delegate property in cellForRowAt function :
extension ViewController : UITableViewDataSource {
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseIdentifier, for: indexPath) as! YoutuberTableViewCell
cell.youtuberLabel.text = youtubers[indexPath.row]
// assign the youtuber model to the cell
cell.youtuber = youtubers[indexPath.row]
// the 'self' here means the view controller, set view controller as the delegate
cell.delegate = self
return cell
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return youtubers.count
}
}
As the view controller needs to conform to the YoutuberTableViewCellDelegate, we will add the following code :
extension ViewController : YoutuberTableViewCellDelegate {
func youtuberTableViewCell(_ youtuberTableViewCell: YoutuberTableViewCell, subscribeButtonTappedFor youtuber: String) { {
// directly use the youtuber saved in the cell
// show alert
let alert = UIAlertController(title: "Subscribed!", message: "Subscribed to \(youtuber)", preferredStyle: .alert)
let okAction = UIAlertAction(title: "OK", style: .default, handler: nil)
alert.addAction(okAction)
self.present(alert, animated: true, completion: nil)
}
}
The code above will be executed when user tap on the subscribe button on each cell, and the index will be passed via this function.
We directly pass the model (ie. Youtuber (string), you can use your own custom model class on your own code) to the tableview cell, so that we won't need to worry about updating the indexPath if there is any row insertion/deletion to the table.
The Closure way
You can read how closure works and optionals if you are not farmiliar with them yet. To use a closure, we will add a closure property (subscribeButtonAction) to the cell class.
//YoutuberTableViewCell.swift
class YoutuberTableViewCell: UITableViewCell {
@IBOutlet weak var youtuberLabel: UILabel!
@IBOutlet weak var subscribeButton: UIButton!
/*
No need to keep track the index since we are using closure to store the function that will be executed when user tap on it.
*/
// the closure, () -> () means take no input and return void (nothing)
// it is wrapped in another parentheses outside in order to make the closure optional
var subscribeButtonAction : (() -> ())?
override func awakeFromNib() {
super.awakeFromNib()
// Initialization code
// Add action to perform when the button is tapped
self.subscribeButton.addTarget(self, action: #selector(subscribeButtonTapped(_:)), for: .touchUpInside)
}
override func setSelected(_ selected: Bool, animated: Bool) {
super.setSelected(selected, animated: animated)
// Configure the view for the selected state
}
@IBAction func subscribeButtonTapped(_ sender: UIButton){
// if the closure is defined (not nil)
// then execute the code inside the subscribeButtonAction closure
subscribeButtonAction?()
}
}
We use a closure type variable subscribeButtonAction which takes in no input and return void (nothing), ie. () -> (), to store the code that will be executed when user tap on the subscribe button. This is like storing a function into a variable, and executing the function by adding parentheses () after the variable name.
We then proceed to add the code that will be executed when user tap on the button in the cellForRowAt method.
extension ViewController : UITableViewDataSource {
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseIdentifier, for: indexPath) as! YoutuberTableViewCell
cell.youtuberLabel.text = youtubers[indexPath.row]
// the code that will be executed when user tap on the button
// notice the capture block has [unowned self]
// the 'self' is the viewcontroller
cell.subscribeButtonAction = { [unowned self] in
let youtuber = self.youtubers[indexPath.row]
let alert = UIAlertController(title: "Subscribed!", message: "Subscribed to \(youtuber)", preferredStyle: .alert)
let okAction = UIAlertAction(title: "OK", style: .default, handler: nil)
alert.addAction(okAction)
self.present(alert, animated: true, completion: nil)
}
return cell
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return youtubers.count
}
}
Notice that there is a [unowned self] at the start of the closure of cell.subscribeButtonAction. This is to prevent retain cycle as the view controller owns the tableview, the tableview owns the cell, the cell owns the subscribeButtonAction closure, and if we use 'self' inside the closure without making it weak/unowned reference, there will be reference cycle like this. :
We can use unowned here because we are sure that the view controller will still be in memory when we tap the button on the tableview cell. Hector has written an excellent article about weak/unowned and retain cycle if you want to read more on it.
The closure approach looks more elegant but keep in mind that each cell will have to allocate memory to store the closure variable (function that will be executed when button tapped), this approach might take quite some memory if the function is large.
Notes
Although this post used delegate / closure on the context of button inside uitableview cell, you can use them on other occassion such as passing data back to the previous view controller , etc.