iosswiftuitableviewuikitdatasource

Update UITableView dataSource during UITableView reloading already


I have UITableView with some sections with some different rows. And during my flow, I have to make several network calls, and when I get the response I want to update my table view. The problem is - I have no idea when I'll get the next response, so sometimes I'm trying to update the data source (make changes with models of cells) and then reload updated sections/cells, when UITableView reloading data already, then the application crashes. Sure I can use tableView.reloadData to safely refresh the view, but I wonder - is there a solution to updating UITableView using methods like reloadCells / insertRows / deleteRows in my case?


Solution

  • The answer depends upon what the crash is:

    1. Are you crashing because UI updates are happening on a background thread?

      If this was the case, Duncan C (+1) is correct that updates from a background thread can result in crashes. I might suggest changing “Strict Concurrency Checking” build setting to “Complete” and it will help identify these issues.

    2. Or, are you crashing with some message along the lines of “Terminating app due to uncaught exception 'NSInternalInconsistencyException”?

      When getting a lot of updates, to manually update, insert, delete and move rows can be brittle. It is all too easy for these to get out of sync when dealing with a combination of multiple network requests, user interaction, etc. Yes, you could solve that by just reloading the whole tableview, but that is not as elegant as just updating the relevant rows/sections.

      While you can batch the updates with performBatchUpdates, a more modern solution is to use a “diffable” data source,UITableViewDiffableDataSource, as discussed in WWDC 2019’s Advances in UI Data Sources video:

      class ViewController: UIViewController {    
          private var dataSource: UITableViewDiffableDataSource<Section, Item>?
          private var items: [Item] = []
      
          override func viewDidLoad() {
              super.viewDidLoad()
      
              dataSource = .init(tableView: tableView) { tableView, indexPath, item in
                  let cell = tableView.dequeueReusableCell(withIdentifier: …, for: indexPath)
                  cell.textLabel?.text = item.string
                  return cell
              }
              tableView.dataSource = dataSource
          }
      }
      

      Note, no UITableViewDataSource methods are required: No “number of sections”, no “number of items”, etc. You only create this diffable datasource and attach it to your table. The diffable data source will figure out the rest for you.

      And anytime you update your model, you build a new snapshot and apply your changes to your data source:

      class ViewController {
          …
      
          func updateDataSource(animated: Bool = true) {
              var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
              snapshot.appendSections([.main])
              snapshot.appendItems(items)
              dataSource?.apply(snapshot, animatingDifferences: animated)
          }
      }
      

      The OS will figure out what rows need to be updated, deleted, inserted, moved, etc.


    Here is a more complete demonstration of diffable datasource used when getting lots of different updates:

    struct Item: Identifiable, Hashable {
        let id = UUID()
        var string: String
    }
    
    class ViewController: UITableViewController {    
        private var dataSource: UITableViewDiffableDataSource<Section, Item>?
        private var items: [Item] = []
        private var tasks: [Task<Void,Error>] = []
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            configureTableView()        
        }
        
        override func viewDidAppear(_ animated: Bool) {
            super.viewDidAppear(animated)
    
            startUpdatingModel()
        }
        
        override func viewDidDisappear(_ animated: Bool) {
            super.viewDidDisappear(animated)
    
            stopUpdatingModel()
        }
    }
    
    // MARK: - Private methods
    
    extension ViewController {    
        func configureTableView() {
            tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
            
            dataSource = .init(tableView: tableView) { tableView, indexPath, item in
                let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
                cell.textLabel?.text = item.string
                return cell
            }
            dataSource?.defaultRowAnimation = .fade
            tableView.dataSource = dataSource
        }
        
        // diffable data source handles insertions, deletions, mutations, and reordering
        
        func startUpdatingModel() {
            // start inserting items slowly
            
            let insertTask = Task {
                for i in 0 ..< 10 {
                    items.append(Item(string: "\(i)"))
                    updateDataSource()
                    try await Task.sleep(for: .seconds(2))
                }
            }
            
            // randomly change text of given item
            
            let mutateTask = Task {
                for i in 0 ..< 100 {
                    if let index = items.indices.randomElement() {
                        items[index].string += "."
                        updateDataSource()
                    }
                    try await Task.sleep(for: .seconds(0.5))
                }
            }
            
            // move random item to start of the array
            
            let moveTask = Task {
                for i in 0 ..< 10 {
                    if let index = items.indices.randomElement() {
                        items.move(fromOffsets: [index], toOffset: 0)
                        updateDataSource()
                    }
                    try await Task.sleep(for: .seconds(5))
                }
            }
            
            tasks = [insertTask, mutateTask, moveTask]
        }
        
        func stopUpdatingModel() {
            for task in tasks {
                task.cancel()
            }
            tasks = []
        }
    
        func updateDataSource(animated: Bool = true) {
            var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
            snapshot.appendSections([.main])
            snapshot.appendItems(items)
            dataSource?.apply(snapshot, animatingDifferences: animated)
        }
    }
    
    extension ViewController {
        enum Section {
            case main
        }
    }
    

    The particulars of how this demonstration is updating the model is not terribly relevant. The key is to just update your model, and then apply a “snapshot” on your data source, and the data source will figure out what updates must take place and just reload those relevant cells for you.