2

Still very much a Swift noob, I have been looking around for a proper way/best practice to manage row deletions in my UITableView (which uses custom UserCells) based on tapping a UIButton inside the UserCell using delegation which seems to be the cleanest way to do it.

I followed this example: UITableViewCell Buttons with action

What I have

UserCell class

protocol UserCellDelegate {

    func didPressButton(_ tag: Int)
}

class UserCell: UITableViewCell {

    var delegate: UserCellDelegate?
    let addButton: UIButton = {

        let button = UIButton(type: .system)

        button.setTitle("Add +", for: .normal)
        button.addTarget(self, action: #selector(buttonPressed), for: .touchUpInside)
        button.translatesAutoresizingMaskIntoConstraints = false
        return button
    }()

    override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
        super.init(style: .subtitle, reuseIdentifier: reuseIdentifier)

        addSubview(addButton)
        addButton.rightAnchor.constraint(equalTo: self.rightAnchor, constant: -6).isActive = true
        addButton.centerYAnchor.constraint(equalTo: self.centerYAnchor).isActive = true
        addButton.heightAnchor.constraint(equalToConstant: self.frame.height / 2).isActive = true
        addButton.widthAnchor.constraint(equalToConstant: self.frame.width / 6).isActive = true
    }

    func buttonPressed(_ sender: UIButton) {

        delegate?.didPressButton(sender.tag)
    }
}

TableViewController class:

class AddFriendsScreenController: UITableViewController, UserCellDelegate {

    let cellId = "cellId"
    var users = [User]()

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

    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {

        let cell = tableView.dequeueReusableCell(withIdentifier: cellId, for: indexPath) as! UserCell

        cell.delegate = self
        cell.tag = indexPath.row

        return cell
    }

    func didPressButton(_ tag: Int) {

        let indexPath = IndexPath(row: tag, section: 0)

        users.remove(at: tag)
        tableView.deleteRows(at: [indexPath], with: .fade)
    }
}

where the Users in users are appended with a call to the database in the view controller.

My issues

  • The button in each row of the Table View is clickable but does not do anything
  • The button seems to be clickable only when doing a "long press", i.e. finger stays on it for a ~0.5s time
  • Will this method guarantee that the indexPath is updated and will not fall out of scope ? I.e. if a row is deleted at index 0, will deleting the "new" row at index 0 work correctly or will this delete the row at index 1 ?

What I want

Being able to click the button in each row of the table, which would remove it from the tableview.

I must be getting something rather basic wrong and would really appreciate if a Swift knight could enlighten me.

Many thanks in advance.

Community
  • 1
  • 1
Herakleis
  • 513
  • 5
  • 21

3 Answers3

5

There are at least 3 issues in your code:

  • In UserCell you should call:
button.addTarget(self, action: #selector(buttonPressed), for: .touchUpInside)

once your cell has been instantiated (say, from your implementation of init(style:reuseIdentifier:)) so that self refers to an actual instance of UserCell.

  • In AddFriendsScreenController's tableView(_:cellForRowAt:) you are setting the tag of the cell itself (cell.tag = indexPath.row) but in your UserCell's buttonPressed(_:) you are using the tag of the button. You should modify that function to be:
func buttonPressed(_ sender: UIButton) {

    //delegate?.didPressButton(sender.tag)
    delegate?.didPressButton(self.tag)
}
  • As you guessed and as per Prema Janoti's answer you ought to reload you table view once you deleted a row as your cells' tags will be out of sync with their referring indexPaths. Ideally you should avoid relying on index paths to identify cells but that's another subject.

EDIT:
A simple solution to avoid tags being out of sync with index paths is to associate each cell with the User object they are supposed to represent:

  • First add a user property to your UserCell class:
class UserCell: UITableViewCell {

    var user = User()   // default with a dummy user

    /* (...) */
}
  • Set this property to the correct User object from within tableView(_:cellForRowAt:):
//cell.tag = indexPath.row
cell.user = self.users[indexPath.row]
  • Modify the signature of your UserCellDelegate protocol method to pass the user property stored against the cell instead of its tag:
protocol UserCellDelegate {

    //func didPressButton(_ tag: Int)
    func didPressButtonFor(_ user: User)

}
  • Amend UserCell's buttonPressed(_:) action accordingly:
func buttonPressed(_ sender: UIButton) {

    //delegate?.didPressButton(sender.tag)
    //delegate?.didPressButton(self.tag)
    delegate?.didPressButtonFor(self.user)
}
  • Finally, in your AddFriendsScreenController, identify the right row to delete based on the User position in the data source:
//func didPressButton(_ tag: Int) { /* (...) */ }   // Scrap this.

func didPressButtonFor(_ user: User) {

    if let index = users.index(where: { $0 === user }) {

        let indexPath = IndexPath(row: index, section: 0)

        users.remove(at: index)
        tableView.deleteRows(at: [indexPath], with: .fade)
    }
}

Note the if let index = ... construct (optional binding) and the triple === (identity operator).

This downside of this approach is that it will create tight coupling between your User and UserCell classes. Best practice would dictate using a more complex MVVM pattern for example, but that really is another subject...

Community
  • 1
  • 1
Olivier
  • 858
  • 8
  • 17
  • thanks for pointing this out, I modified accordingly and it works fine. I changed the `cell.tag` to `cell.addButton.tag` instead of what you suggested based on the same principle. When you say one should avoid relying on index paths to identify cells, what problems could this potentially generate and is there a standard/smart approach to it ? Thanks again ! – Herakleis Feb 14 '17 at 14:50
  • I updated my answer to provide an alternative to using `indexPaths` - with a caveat. Hope this helps as a starting point! – Olivier Feb 14 '17 at 15:53
  • Thank you @Olivier, this looks straightforward. Will check it out ! – Herakleis Feb 14 '17 at 16:10
  • @Olivier can you show me how it can be done in MVVM? I was trying my luck for a while. I couldnt find any resources either. – Sree May 19 '18 at 18:02
  • The first point you made on this answer is what catches people out, thank you for the answer. Trying to add an action with a target of self before the object is initiated means no action added. – Dom Bryan Jun 04 '20 at 19:19
2

There is a lot of bad/old code on the web, even on SO. What you posted has "bad practice" written all over it. So first a few pointers:

  • Avoid an UITableViewController at all cost. Have a normal view controller with a table view on it
  • Delegates should always be weak unless you are 100% sure what you are doing
  • Be more specific when naming protocols and protocol methods
  • Keep everything private if possible, if not then use fileprivate. Only use the rest if you are 100% sure it is a value you want to expose.
  • Avoid using tags at all cost

The following is an example of responsible table view with a single cell type which has a button that removes the current cell when pressed. The whole code can be pasted into your initial ViewController file when creating a new project. In storyboard a table view is added constraint left, right, top, bottom and an outlet to the view controller. Also a cell is added in the table view with a button in it that has an outlet to the cell MyTableViewCell and its identifier is set to "MyTableViewCell".

The rest should be explained in the comments.

class ViewController: UIViewController {

    @IBOutlet private weak var tableView: UITableView? // By default use private and optional. Always. For all outlets. Only expose it if you really need it outside

    fileprivate var myItems: [String]? // Use any objects you need.


    override func viewDidLoad() {
        super.viewDidLoad()

        // Attach table viw to self
        tableView?.delegate = self
        tableView?.dataSource = self

        // First refresh and reload the data
        refreshFromData() // This is to ensure no defaults are visible in the beginning
        reloadData()
    }

    private func reloadData() {

        myItems = nil

        // Simulate a data fetch
        let queue = DispatchQueue(label: "test") // Just for the async example

        queue.async {
            let items: [String] = (1...100).flatMap { "Item: \($0)" } // Just generate some string
            Thread.sleep(forTimeInterval: 3.0) // Wait 3 seconds
            DispatchQueue.main.async { // Go back to main thread
                self.myItems = items // Assign data source to self
                self.refreshFromData() // Now refresh the table view
            }
        }
    }

    private func refreshFromData() {
        tableView?.reloadData()
        tableView?.isHidden = myItems == nil
        // Add other stuff that need updating here if needed
    }

    /// Will remove an item from the data source and update the array
    ///
    /// - Parameter item: The item to remove
    fileprivate func removeItem(item: String) {

        if let index = myItems?.index(of: item) { // Get the index of the object

            tableView?.beginUpdates() // Begin updates so the table view saves the current state
            myItems = myItems?.filter { $0 != item } // Update our data source first
            tableView?.deleteRows(at: [IndexPath(row: index, section: 0)], with: .fade) // Do the table view cell modifications
            tableView?.endUpdates() // Commit the modifications
        }

    }

}

// MARK: - UITableViewDelegate, UITableViewDataSource

extension ViewController: UITableViewDelegate, UITableViewDataSource {

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return myItems?.count ?? 0
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        if let cell = tableView.dequeueReusableCell(withIdentifier: "MyTableViewCell", for: indexPath) as? MyTableViewCell {
            cell.item = myItems?[indexPath.row]
            cell.delegate = self
            return cell
        } else {
            return UITableViewCell()
        }
    }

}

// MARK: - MyTableViewCellDelegate

extension ViewController: MyTableViewCellDelegate {

    func myTableViewCell(pressedMainButton sender: MyTableViewCell) {

        guard let item = sender.item else {
            return
        }

        // Delete the item if main button is pressed
        removeItem(item: item)

    }

}



protocol MyTableViewCellDelegate: class { // We need ": class" so the delegate can be marked as weak

    /// Called on main button pressed
    ///
    /// - Parameter sender: The sender cell
    func myTableViewCell(pressedMainButton sender: MyTableViewCell)

}

class MyTableViewCell: UITableViewCell {

    @IBOutlet private weak var button: UIButton?

    weak var delegate: MyTableViewCellDelegate? // Must be weak or we can have a retain cycle and create a memory leak

    var item: String? {
        didSet {
            button?.setTitle(item, for: .normal)
        }
    }

    @IBAction private func buttonPressed(_ sender: Any) {

        delegate?.myTableViewCell(pressedMainButton: self)

    }
}

In your case the String should be replaced by the User. Next to that you will have a few changes such as the didSet in the cell (button?.setTitle(item.name, for: .normal) for instance) and the filter method should use === or compare some id or something.

Matic Oblak
  • 16,318
  • 3
  • 24
  • 43
  • Thanks @Matic, will give it a go and study your example. Any reason for the `UITableViewController` to be "avoided at all costs" ? Thanks a bunch. – Herakleis Feb 14 '17 at 16:17
  • @Herakleis The main reason is that this controller replaces its view (controller.view) from the traditional UIView to its subclass UITableView which is a scroll view. That means any view you add to the controller.view as a subview will scroll. So all the overlays are a pain to add. Also it disables all the control on where the table view is so it is very hard to position the content within some other views (headers, footers, side bars). On the other hand there is practically no benefit in using it comparing to a table view added as a subview of the normal view controller. – Matic Oblak Feb 14 '17 at 17:17
  • Check how I simply hide the table view before we get any data. We could add a simple activity indicator which would be shown in the same case and hidden when the data is received. We could do the same with some overlay if no data is received (empty array)... Hiding the table view in your case would mean hiding all its subviews, overlays you add... – Matic Oblak Feb 14 '17 at 17:20
  • thanks a lot, makes sense. Will implement stuff going forward like this. – Herakleis Feb 15 '17 at 13:16
  • As a follow-up @Matic: how should I go about doing this programmatically ? `@IBOutlet private weak var tableView: UITableView?`. Wanted to declare a `private weak var` and set up properties thereafter but would "lose" the optional property – Herakleis Feb 16 '17 at 09:51
  • I am not sure I understand that. If you do it programmatically you just remove the @IBOutlet. Then in viewDidLoad you write something like "let tableView = UITableView(frame: view.bounds) view.addSubview(tableView) ... self.tableView = tableView" – Matic Oblak Feb 16 '17 at 20:23
0

try this -

update didPressButton method like below -

 func didPressButton(_ tag: Int) {
    let indexPath = IndexPath(row: tag, section: 0)
    users.remove(at: tag)
    tableView.reloadData()
}
Prema Janoti
  • 836
  • 5
  • 18