nouatzi
nouatzi

Reputation: 785

UITableView unexpectedly bounces with beginUpdates()/endUpdates()/performBatchUpdates() using NSFetchedResultsController and CoreData

UITableView unexpectedly bounces with beginUpdates() / endUpdates() / performBatchUpdates() using NSFetchedResultsController and CoreData when the number of rows fill the view. It's pretty simple to reproduce. - Create a new project from the Master-Detail App Template (with CoreData). - In the storyboard, remove the "showDetail" segue. (we don't need the detail view) - In MasterViewController, replace segue func prepare() with :

override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {  
    let event = fetchedResultsController.object(at: indexPath)  
    let timestamp = event.timestamp  
    event.timestamp = timestamp // The idea is to simply update the Event entity.  
  }

Launch the app (in iOS devices or Simulators), and add enough rows to fill the view (in iPhone SE, it 11 rows). Scroll down the view, and select any row. The view WILL rapidly BOUNCE up and down. Is that a bug, or is there an issue with the code ?

Upvotes: 4

Views: 1255

Answers (4)

nouatzi
nouatzi

Reputation: 785

A more refined solution is

  lazy var sectionChanges = [() -> Void]()
  lazy var objectChanges = [() -> Void]()

  func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
    guard controller == self._fetchedResultsController else { return }
    self.sectionChanges.removeAll()
    self.objectChanges.removeAll()
  }

  func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange sectionInfo: NSFetchedResultsSectionInfo, atSectionIndex sectionIndex: Int, for type: NSFetchedResultsChangeType) {
    guard controller == self._fetchedResultsController else { return }
    let sections = IndexSet(integer: sectionIndex)
    self.sectionChanges.append { [unowned self] in
      switch type {
      case .insert: self.tableView.insertSections(sections, with: .fade)
      case .delete: self.tableView.deleteSections(sections, with: .fade)
      default: break
      }
    }
  }

  func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {
    guard controller == self._fetchedResultsController else { return }
    switch type {
    case .insert:
      if let verifiedNewIndexPath = newIndexPath {
        self.objectChanges.append { [unowned self] in
          self.tableView.insertRows(at: [verifiedNewIndexPath], with: .fade)
        }
      }
    case .delete:
      if let verifiedIndexPath = indexPath {
        self.objectChanges.append { [unowned self] in
          self.tableView.deleteRows(at: [verifiedIndexPath], with: .fade)
        }
      }
    case .update:
      if let verifiedIndexPath = indexPath, let event = anObject as? Event, let cell = self.tableView.cellForRow(at: verifiedIndexPath) {
        self.configureCell(cell, withEvent: event)
      }
    case .move:
      if let verifiedIndexPath = indexPath, let verifiedNewIndexPath = newIndexPath, let event = anObject as? Event, let cell = self.tableView.cellForRow(at: verifiedIndexPath) {
        self.configureCell(cell, withEvent: event)
        self.objectChanges.append { [unowned self] in
          self.tableView.moveRow(at: verifiedIndexPath, to: verifiedNewIndexPath)
        }
      }
    default: break
    }
  }

  func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
    guard controller == self._fetchedResultsController else { return }
    guard self.objectChanges.count > 0 || self.sectionChanges.count > 0 else { return }
    self.tableView.performBatchUpdates({[weak self] in
      self?.objectChanges.forEach { $0() }
      self?.sectionChanges.forEach { $0() }
    }) { (finished) in
      // here I check if the tableView is empty. If so, I usually add a label saying "no item, click add button to add items."
      // If not, then I remove this label.
    }
  }

Upvotes: 1

adam.wulf
adam.wulf

Reputation: 2169

I noticed the similar (duplicate?) question at UITableView unexpectedly bounces with beginUpdates()/endUpdates()/performBatchUpdates()

I added an answer there about using the estimatedHeightFor... methods of the table view. Implementing these methods to return a positive number fixes the odd bounce problem during table view batch updates.

Upvotes: 0

nouatzi
nouatzi

Reputation: 785

Ok, I might have found a solution, please tell me guys what you think. The idea would be to process insert/delete/move in performBatchUpdates and leave update out of it. So I've created this enum and property:

enum FetchedResultsChange<Object> {
  case insert(IndexPath)
  case delete(IndexPath)
  case move(IndexPath, IndexPath, Object)
}
var fetchedResultsChanges: [FetchedResultsChange<Event>] = []

And controllerWillChangeContent becomes empty:

func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {}

didChange becomes:

func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {
    switch type {
    case .insert:
      self.fetchedResultsChanges.append(.insert(newIndexPath!))
    case .delete:
      self.fetchedResultsChanges.append(.delete(indexPath!))
    case .update:
      configureCell(tableView.cellForRow(at: indexPath!)!, withEvent: anObject as! Event) // So this stays untouched.
    case .move:
      self.fetchedResultsChanges.append(.move(indexPath!, newIndexPath!, anObject as! Event))
    }
  }

And controllerDidChangeContent becomes:

  func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
    guard self.fetchedResultsChanges.count > 0 else { return }

    tableView.performBatchUpdates({
      repeat {
        let change = self.fetchedResultsChanges.removeFirst()
        switch change {
        case .insert(let newIndexPath):
          tableView.insertRows(at: [newIndexPath], with: .fade)
        case .delete(let indexPath):
          tableView.deleteRows(at: [indexPath], with: .fade)
        case .move(let indexPath, let newIndexPath, let event):
          configureCell(tableView.cellForRow(at: indexPath)!, withEvent: event)
          tableView.moveRow(at: indexPath, to: newIndexPath)
        }
      } while self.fetchedResultsChanges.count > 0
    }, completion: nil)
  }

So what do you think ?

Upvotes: 3

Pankaj
Pankaj

Reputation: 397

This may help -

UIView.performWithoutAnimation {
                                self.tableView?.beginUpdates()
                                let contentOffset = self.tableView?.contentOffset
                                self.tableView?.reloadRows(at: [IndexPath(row: j, section: 0)], with: .automatic)
                                self.tableView?.setContentOffset(contentOffset!, animated: false)
                                self.tableView?.endUpdates()
                            }

Upvotes: -2

Related Questions