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

I have a pretty straight forward UITableViewController /NSFetchedResultsController case here. It's from Xcode Master-Detail App sample code.

I have a CoreData Model with 1 Entity (Event) with 1 String Attribute (aString). It's displayed in a UITableViewController. I use .subtitle system cell type. By selecting a row, I simply update the String Attribute.

So my problem is, when I insert just enough rows for the tableview to scroll (10-11 rows on a iPhone 5s with navbar), and I scroll down, and select any row, the tableview bounces up and down.

If there is less rows (less than 10 rows), or more rows (12 rows and more), the behaviour is normal, no unexpected bounce.

So the problem seems to happen at the limit of the scroll view. If I don't use beginUpdates()/endUpdates(), the problem goes away, but I loose their advantages.

Here is a video link of what happens https://youtu.be/23e4KyyoZcw


class TableViewController: UITableViewController, NSFetchedResultsControllerDelegate {


  var managedObjectContext: NSManagedObjectContext? = nil


  @objc func insertNewObject(_ sender: Any) {
    let context = self.fetchedResultsController.managedObjectContext
    let newEvent = Event(context: context)
    newEvent.aString = "a String"
    try? context.save()
  }


  override func viewDidLoad() {
    super.viewDidLoad()


    let addButton = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(insertNewObject(_:)))
    navigationItem.rightBarButtonItem = addButton
  }


  override func numberOfSections(in tableView: UITableView) -> Int {
    return fetchedResultsController.sections?.count ?? 0
  }


  override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    let sectionInfo = fetchedResultsController.sections![section]
    return sectionInfo.numberOfObjects
  }


  override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
    let event = fetchedResultsController.object(at: indexPath)
    configureCell(cell, withEvent: event)
    return cell
  }


  func configureCell(_ cell: UITableViewCell, withEvent event: Event) {
    cell.textLabel?.text = event.aString
    cell.detailTextLabel?.text = event.aString
  }


  override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    tableView.deselectRow(at: indexPath, animated: true)
    let event: Event = self.fetchedResultsController.object(at: indexPath)
    event.aString = event.aString! + ""
  }


  override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
    return true
  }


  override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) {
    if editingStyle == .delete {
      let context = fetchedResultsController.managedObjectContext
      context.delete(fetchedResultsController.object(at: indexPath))
      try? context.save()
    }
  }


  var fetchedResultsController: NSFetchedResultsController<Event> {
    if _fetchedResultsController != nil {
      return _fetchedResultsController!
    }


    let fetchRequest: NSFetchRequest<Event> = Event.fetchRequest()
    fetchRequest.fetchBatchSize = 20
    let sortDescriptor = NSSortDescriptor(keyPath: \Event.aString, ascending: false)
    fetchRequest.sortDescriptors = [sortDescriptor]


    let aFetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: self.managedObjectContext!, sectionNameKeyPath: nil, cacheName: nil)
    aFetchedResultsController.delegate = self
    _fetchedResultsController = aFetchedResultsController
    try? _fetchedResultsController!.performFetch()


    return _fetchedResultsController!
  }


  var _fetchedResultsController: NSFetchedResultsController<Event>? = nil


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


  func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange sectionInfo: NSFetchedResultsSectionInfo, atSectionIndex sectionIndex: Int, for type: NSFetchedResultsChangeType) {
    switch type {
    case .insert:
      tableView.insertSections(IndexSet(integer: sectionIndex), with: .automatic)
    case .delete:
      tableView.deleteSections(IndexSet(integer: sectionIndex), with: .automatic)
    default:
      return
    }
  }


  func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {
    switch type {
    case .insert:
      tableView.insertRows(at: [newIndexPath!], with: .automatic)
    case .delete:
      tableView.deleteRows(at: [indexPath!], with: .automatic)
    case .update:
      configureCell(tableView.cellForRow(at: indexPath!)!, withEvent: anObject as! Event)
    case .move:
      configureCell(tableView.cellForRow(at: indexPath!)!, withEvent: anObject as! Event)
      tableView.moveRow(at: indexPath!, to: newIndexPath!)
    }
  }


  func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
    tableView.endUpdates()
  }
}