简体   繁体   中英

Properly delegate button action from custom Cell to delete rows in UITableView

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 UserCell s) 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 User s 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", ie 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 ? Ie 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.

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...

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.

try this -

update didPressButton method like below -

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

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM