iosswiftuitableviewuiviewcontrolleruitableviewdiffabledatasource

Swipe delete in UIViewController with UITableView and UITableViewDiffableDataSource


I have a UIViewController with a UICollectionView and a UITableView. Both views use UICollectionViewDiffableDataSource and UITableViewDiffableDataSource respectively.

In the table, I am trying to set up swipe functionality for the delete action. It was a very simple task with a regular data source, but I can't get it to work with the diffable one.

I tried creating a custom class for UITableViewDiffableDataSource, but I wasn't even able to compile the code with my attempts to access the property from the controller. (I needed to access the property storing the model to delete not only the row, but also the data.) Using UITableViewDelegate with a tableView(leadingSwipeActionsConfigurationForRowAt:) did compile, and I could swipe, but the app crashed with an error: UITableView must be updated via the UITableViewDiffableDataSource APIs when acting as the UITableView's dataSource.

How do I do it? Is there a best practice for a job like this?

Edit 1:
As requested in the comments, I'm providing some of my code, for the most recent attempt at implementation.

In the UIViewController (it's a long implementation, so I left out most of it):

class ViewController: UIViewController, ItemsTableViewDiffableDataSourceDelegate {

    @IBOutlet var tableView: UITableView!

    var items = [Item]()
    var tableViewDataSource: ItemsTableViewDiffableDataSource!
    var itemsSnapshot: NSDiffableDataSourceSnapshot<String, Item> {
        var snapshot = NSDiffableDataSourceSnapshot<String, Item>()
        snapshot.appendSections(["Items"])
        snapshot.appendItems(items)
        return snapshot
    }

    func configureTableViewDataSource() {
        // A working implementation
    }

    func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
        let deleteAction = UIContextualAction(style: .destructive, title: "Delete") { action, view, handler in
            self.items.remove(at: indexPath.row)
            tableView.deleteRows(at: [indexPath], with: .fade)
        }
        deleteAction.backgroundColor = .red
        let configuration = UISwipeActionsConfiguration(actions: [deleteAction])
        configuration.performsFirstActionWithFullSwipe = false
        return configuration
    }
}

In the UITableViewDiffableDataSource (complete implementation):

@MainActor
class ItemsTableViewDiffableDataSource: UITableViewDiffableDataSource<String, Item> {
}

protocol ItemsTableViewDiffableDataSourceDelegate: AnyObject {
    func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration?
}

Originally, I had the tableView(leadingSwipeActionsConfigurationForRowAt:) method defined within the data source class, but I couldn't access the items property, so I tried it with a protocol. Either way, the implementation doesn't work — I'm unable to swipe, let alone delete items and table rows.

Edit 2:
I guess the real question is — how do I register the tableView(leadingSwipeActionsConfigurationForRowAt:) method with my tableView. Still, my guess is that my implementation is faulty in general.


Solution

  • It turns out that the implementation was fairly simple. A custom UITableViewDiffableDataSource class wasn't needed in my case. The entirety of the implementation went into the view controller.

    class ViewController: UIViewController, UITableViewDelegate {
        
        @IBOutlet var tableView: UITableView!
        
        var items = [Item]()
        var tableViewDataSource: UITableViewDiffableDataSource<String, Item>!
        var itemsSnapshot: NSDiffableDataSourceSnapshot<String, Item> {
            var snapshot = NSDiffableDataSourceSnapshot<String, Item>()
            snapshot.appendSections(["Items"])
            snapshot.appendItems(items)
            return snapshot
        }
    
        override func viewDidLoad() {
            super.viewDidLoad()
            configureTableViewDataSource()
            tableView.delegate = self
        }
            
        func configureTableViewDataSource() {
            tableViewDataSource = UITableViewDiffableDataSource<String, Item>(tableView: tableView, cellProvider: { (tableView, indexPath, itemIdentifier) -> UITableViewCell? in
                let cell = tableView.dequeueReusableCell(withIdentifier: ItemTableViewCell.reuseIdentifier, for: indexPath) as! ItemTableViewCell
                cell.configureCell(for: itemIdentifier)
                return cell
            })
        }
        
        func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
            let deleteAction = UIContextualAction(style: .destructive, title: "Delete") { action, view, handler in
                self.items.remove(at: indexPath.row)
                self.tableViewDataSource.apply(self.itemsSnapshot, animatingDifferences: true)
            }
            deleteAction.backgroundColor = .red
            let configuration = UISwipeActionsConfiguration(actions: [deleteAction])
            configuration.performsFirstActionWithFullSwipe = true
            return configuration
        }
    }
    

    Remember to set the view controller as the delegate of the table view, which you can do in viewDidLoad(). (Thanks to HangarRash for the hint in the comments.)

    My first mistake when trying this approach was that I still tried to update the data by acting directly on the table view. This will give you an error that reads:

    *** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'UITableView must be updated via the UITableViewDiffableDataSource APIs when acting as the UITableView's dataSource: please do not call mutation APIs directly on UITableView.

    Instead call self.tableViewDataSource.apply(self.itemsSnapshot, animatingDifferences: true).