正确委派自定义 Cell 中的按钮操作以删除 UITableView 中的行

Posted

技术标签:

【中文标题】正确委派自定义 Cell 中的按钮操作以删除 UITableView 中的行【英文标题】:Properly delegate button action from custom Cell to delete rows in UITableView 【发布时间】:2017-02-14 12:32:25 【问题描述】:

仍然是一个 Swift 菜鸟,我一直在寻找一种适当的方法/最佳实践来管理我的 UITableView(它使用自定义 UserCells)中的行删除,基于在 @ 内点击 UIButton 987654325@ 使用委托,这似乎是最干净的方式。

我按照这个例子:UITableViewCell Buttons with action

我有什么

UserCell 类

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

users 中的 Users 在视图控制器中附加了对数据库的调用。

我的问题

表格视图每一行中的按钮都是可点击的,但没有任何作用 该按钮似乎只有在“长按”时才可点击,即手指在其上停留约 0.5 秒的时间 此方法能否保证indexPath 已更新且不会超出范围? IE。如果在索引 0 处删除了一行,删除索引 0 处的“新”行会正常工作还是会删除索引 1 处的行?

我想要什么

能够单击表格每一行中的按钮,这会将其从表格视图中删除。

我一定是弄错了一些基本错误,如果 Swift 骑士能启发我,我将不胜感激。

非常感谢。

【问题讨论】:

【参考方案1】:

您的代码中至少有 3 个问题:

UserCell 你应该打电话:
button.addTarget(self, action: #selector(buttonPressed), for: .touchUpInside)

一旦您的单元格被实例化(例如,从您的init(style:reuseIdentifier:) 实现),self 将引用UserCell 的实际实例。

AddFriendsScreenControllertableView(_:cellForRowAt:) 中,您正在设置单元格本身的标签(cell.tag = indexPath.row),但在UserCellbuttonPressed(_:) 中,您使用的是按钮的标签。您应该将该函数修改为:
func buttonPressed(_ sender: UIButton) 

    //delegate?.didPressButton(sender.tag)
    delegate?.didPressButton(self.tag)

正如您所猜测的那样,根据Prema Janoti's answer,您应该在删除一行后重新加载表格视图,因为您的单元格标签将与其引用的indexPaths 不同步。理想情况下,您应该避免依赖索引路径来识别单元格,但这是另一个主题。

编辑: 避免标签与索引路径不同步的一个简单解决方案是将每个单元格与它们应该表示的 User 对象相关联:

首先将user 属性添加到您的UserCell 类:
class UserCell: UITableViewCell 

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

    /* (...) */

将此属性设置为来自tableView(_:cellForRowAt:) 内的正确User 对象:
//cell.tag = indexPath.row
cell.user = self.users[indexPath.row]
修改您的UserCellDelegate 协议方法的签名以传递针对单元格存储的user 属性,而不是其tag
protocol UserCellDelegate 

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


相应地修改UserCellbuttonPressed(_:) 操作:
func buttonPressed(_ sender: UIButton) 

    //delegate?.didPressButton(sender.tag)
    //delegate?.didPressButton(self.tag)
    delegate?.didPressButtonFor(self.user)

最后,在您的AddFriendsScreenController 中,根据数据源中的User 位置确定要删除的正确行:
//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)
    

注意 if let index = ... 构造 (optional binding) 和三重 === (identity operator)。

这种方法的缺点是它会在 UserUserCell 类之间创建紧密耦合。例如,最佳实践会要求使用更复杂的MVVM pattern,但这确实是另一个主题......

【讨论】:

感谢您指出这一点,我进行了相应的修改,效果很好。我将cell.tag 更改为cell.addButton.tag,而不是您基于相同原则建议的内容。当您说应该避免依赖索引路径来识别单元格时,这可能会产生什么问题,是否有标准/智能方法?再次感谢! 我更新了我的答案以提供使用 indexPaths 的替代方法 - 需要注意的是。希望这有助于作为一个起点! 谢谢@Olivier,这看起来很简单。会检查的! @Olivier 你能告诉我如何在 MVVM 中完成它吗?我尝试了一段时间的运气。我也找不到任何资源。 您在此答案上提出的第一点是吸引人们的地方,谢谢您的回答。在对象启动之前尝试添加目标为 self 的操作意味着未添加任何操作。【参考方案2】:

网络上有很多坏/旧代码,即使在 SO 上也是如此。您发布的内容到处都是“不良做法”。所以先提几点建议:

不惜一切代价避免UITableViewController。有一个普通的视图控制器,上面有一个表格视图 除非您 100% 确定自己在做什么,否则代表应始终为 weak 在命名协议和协议方法时更加具体 如果可能,请保留所有内容private,否则请使用fileprivate。仅当您 100% 确定它是您想要公开的值时才使用其余部分。 不惜一切代价避免使用标签

以下是具有单个单元格类型的负责表视图的示例,该单元格类型有一个按钮,当按下时删除当前单元格。创建新项目时,可以将整个代码粘贴到您的初始 ViewController 文件中。在情节提要中,表格视图被添加约束左、右、上、下和视图控制器的出口。此外,表格视图中还添加了一个单元格,其中有一个按钮,该按钮具有到单元格 MyTableViewCell 的出口,并且其标识符设置为“MyTableViewCell”。

其余的应该在cmets中说明。

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)

    

在您的情况下,String 应替换为 User。接下来,您将进行一些更改,例如单元格中的didSet(例如button?.setTitle(item.name, for: .normal)),过滤方法应使用===或比较一些id或其他内容。

【讨论】:

感谢@Matic,将试一试并研究您的示例。 UITableViewController 有什么理由“不惜一切代价避免”?非常感谢。 @Herakleis 主要原因是这个控制器将它的视图(controller.view)从传统的 UIView 替换为它的子类 UITableView,它是一个滚动视图。这意味着您添加到 controller.view 作为子视图的任何视图都将滚动。所以所有的叠加层都很难添加。此外,它还禁用了对表格视图位置的所有控制,因此很难在其他一些视图(页眉、页脚、侧边栏)中定位内容。另一方面,与作为普通视图控制器的子视图添加的表格视图相比,使用它实际上没有任何好处。 在我们获取任何数据之前检查我是如何简单地隐藏表格视图的。我们可以添加一个简单的活动指示器,该指示器将在相同的情况下显示并在收到数据时隐藏。如果没有收到数据(空数组),我们可以对一些覆盖做同样的事情......在你的情况下隐藏表格视图意味着隐藏它的所有子视图,你添加的覆盖...... 非常感谢,有道理。将像这样实现未来的东西。 作为后续@Matic:我应该如何以编程方式执行此操作? @IBOutlet private weak var tableView: UITableView?。想要声明 private weak var 并在此后设置属性,但会“丢失”可选属性【参考方案3】:

试试这个 -

更新didPressButton 方法如下 -

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

【讨论】:

以上是关于正确委派自定义 Cell 中的按钮操作以删除 UITableView 中的行的主要内容,如果未能解决你的问题,请参考以下文章

tableview左滑按钮 tableviewcell自定义左滑按钮

UI测试滑动删除表格视图单元格

自定义tableViewCell的侧滑删除按钮

如何在 Kendo UI 网格中创建自定义删除/销毁按钮/命令?

通过滑动来调整 UILabel 文本的大小以删除

swift 自定义view VFL 设置约束冲突