UITableView jumps up after begin/endUpdates when using UITableViewAutomaticDimension

Actually I found a nice method to fix this.. It drove me crazy but look:

So you

  • Have a table with expandable content
  • Wanna animate a cell's constraints (height for example)
  • And therefore you call tableView.beginUpdates() and tableView.endUpdates()

And soo the table jumps..

As others have said before, it is because updating the tableView makes the tableView Scroll

The solution?

Let's assume your code looks like this:

override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        let cell = tableView.cellForRow(at: indexPath)
        cell.heightConstraint = 100
        UIView.animate(withDuration: 0.15, animations: {

            self.view.layoutIfNeeded()
            self.tableView.beginUpdates()
            self.tableView.endUpdates()
        }, completion: nil)
}

Then to fix the jumping issue you have to save the current tableView scroll position until the tableView.endUpdates() being called.

Like this:

var currentScrollPos : CGFloat?

override func scrollViewDidScroll(_ scrollView: UIScrollView) {
        // Force the tableView to stay at scroll position until animation completes
        if (currentScrollPos != nil){
            tableView.setContentOffset(CGPoint(x: 0, y: currentScrollPos!), animated: false)
        }
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        let cell = tableView.cellForRow(at: indexPath)
        cell.heightConstraint = 100
        UIView.animate(withDuration: 0.15, animations: {

            self.currentScrollPos = self.tableView.contentOffset.y

            self.view.layoutIfNeeded()
            self.tableView.beginUpdates()
            self.tableView.endUpdates()

            self.currentScrollPos = nil
        }, completion: nil)
}

After playing a bit around with this issue it seems to be a bug in table view itself. Calling endUpdates triggers a delegate to func scrollViewDidScroll(_ scrollView: UIScrollView) which reports incorrect content offset. which is later reset back to what it was but the second call seems to be animated where the first is not.

After further inspection this seems to only happen when table view is scrolled down enough for it to think that its overall content height is not large enough for its content offset to be valid. I guess this is possible due to content insets, automatic row heights and estimated row heights.

It is hard to say where exactly the bug lies but let's get to the fixes:

For those that are using automatic row height the quick fix will most likely be simply increasing estimated row height to whatever you expect the largest cell to be. And for most cases increasing it to some extremely large value will produce no issues at all.

For the rest that can't afford to change estimated row height you can fix the issue with injecting a very large footer view before doing the updates. See the following code to get the picture (comment should be the best explanation on what is going on).

    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {

        // Modify data source
        let currentResult = results[indexPath.row] // Fetch object
        let toggledResult = (object: currentResult.object, extended: !currentResult.extended) // Toggle being extended
        results[indexPath.row] = toggledResult // Assign it back to array

        // Find the cell
        if let cell = tableView.cellForRow(at: indexPath) as? ResultTableViewCell {
            let currentFooterView = tableView.tableFooterView // Steal the current footer view
            let newView = UIView(frame: CGRect(x: 0.0, y: 0.0, width: tableView.bounds.width, height: tableView.bounds.height*2.0)) // Create a new footer view with large enough height (Really making sure it is large enough)
            if let currentFooterView = currentFooterView {
                // Put the current footer view as a subview to the new one if it exists (this was not really tested)
                currentFooterView.frame.origin = .zero // Just in case put it to zero
                newView.addSubview(currentFooterView) // Add as subview
            }
            tableView.tableFooterView = newView // Assign a new footer

            // Doing standard animations
            UIView.animate(withDuration: 0.3, animations: {
                cell.resultObject = toggledResult // This will trigger internal cell animations

                // Standard refresh
                tableView.beginUpdates()
                tableView.endUpdates()
                tableView.tableFooterView = currentFooterView // Put the footer view back
            })
        }

    }

For the reference this is the code without the jumping fix:

    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {

        // Modify data source
        let currentResult = results[indexPath.row] // Fetch object
        let toggledResult = (object: currentResult.object, extended: !currentResult.extended) // Toggle being extended
        results[indexPath.row] = toggledResult // Assign it back to array

        // Find the cell
        if let cell = tableView.cellForRow(at: indexPath) as? ResultTableViewCell {
            // Doing standard animations
            UIView.animate(withDuration: 0.3, animations: {
                cell.resultObject = toggledResult // This will trigger internal cell animations

                // Standard refresh
                tableView.beginUpdates()
                tableView.endUpdates()
            })
        }

    }

Note not to apply both of the fixes at the same time. Having a large estimated height plus changing footer will break the whole logic when scrolled to bottom and a cell collapses (resizes to a smaller height). So if you have an issue at that point check your estimated height is not too high.


You can also prevent animations by using UIView.performWithoutAnimation

It's pretty simple:

UIView.performWithoutAnimation {
    self.tableView.beginUpdates()
    self.tableView.endUpdates()
}

However, I have noticed some weird UI issues if this is used. You can reload a tableView with the following extension:

extension UITableView {
  func reloadWithoutScroll() {
    let offset = contentOffset
    reloadData()
    layoutIfNeeded()
    setContentOffset(offset, animated: false)
  }
}

Since the scroll position is saved and reset after the reload you shouldn't see any "jumping".