35

The problem: When I'm at the bottom of the UITableView (and only when at the bottom) and tableView.beginUpdates()/tableView.endUpdates() is called, the UITableView jumps up a little bit. I don't want it to do this.

Set Up: I have a UITableView with UITableViewCells that will all be different sizes, and I'm using UITableViewAutomaticDimension to size the cells.

Example: When I'm at the bottom of the UITableView and I select a cell, which subsequently calls tableView.beginUpdates()/tableView.endUpdates(), the UITableView scrolls/jumps up just a bit.

Has anyone found a way to make it so there's no jumpiness after calling tableView.beginUpdates()/tableView.endUpdates() while at the end of the UITableView?

For the time being I'm calculating all my UITableViewCells manually, I'd rather not do this though because I'd like to take advantage of UITableViewAutomaticDimension.

Code:

Here's some code to display what goes on between tableView.beginUpdates()/tableView.endUpdates():

    tableView.beginUpdates()
    cell!.setNeedsUpdateConstraints()
    cell!.updateConstraintsIfNeeded()
    cell!.setNeedsLayout()
    cell!.layoutIfNeeded()
    tableView.endUpdates()

My viewWillAppear:

public override func viewWillAppear(animated: Bool) {
    super.viewWillAppear(animated)

    self.tableView.estimatedRowHeight = 150.0
    self.tableView.rowHeight = UITableViewAutomaticDimension


    self.tableView.reloadData()
    //Bug in 8.0+ forces us to call three extra methods to get dynamic cell heights to work correctly...
    self.tableView.setNeedsLayout()
    self.tableView.layoutIfNeeded()
    self.tableView.reloadData()
}

My heightForRow:

public override func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {

    return UITableViewAutomaticDimension

}

Update: Removing cell!.setNeedsUpdateConstraints(), cell!.updateConstraintsIfNeeded(), cell!.setNeedsLayout(), and cell!.layoutIfNeeded() from tableView.beginUpdates()/tableView.endUpdates() appears to stop the jumping, but then my cells do not resize...

Edit: This only occurs when at the bottom of the UITableView.

Sakiboy
  • 7,252
  • 7
  • 52
  • 69
  • I'd like to mention that I've witnessed this on both `iOS 8` and `iOS 9`. – Sakiboy Nov 18 '15 at 20:34
  • show some code may be? because I have never had the tableView jump when I used beginUpdates()/endUpdates(), I'm curious whats being done inside them. – Shabarinath Pabba Nov 18 '15 at 20:45
  • Added some code, it's all pretty basic "by the book" code though... – Sakiboy Nov 18 '15 at 20:50
  • 1
    This is strange. I haven't had a problem with it myself. Have you tried time profiling the code with instruments? It will generate relative percentages for how long each line of code executes. This might help you to narrow it down a bit. – Gordonium Nov 18 '15 at 20:53
  • This project is fairly large, and the cells are all of different classes, they can be any height, and can have any number of various UI components in them, so I've been dealing with the troubles of `UITableView` for some time now... I have not done any time profiling for this though. – Sakiboy Nov 18 '15 at 21:02
  • Just disable animation from UIView class before the "begin update" and enable it back after the "end update" – Dekel Maman Jul 21 '16 at 07:34
  • 2
    Here a link that may fix your jumping tableview problem. [jumping tableview after end updates](http://stackoverflow.com/questions/28917923/uitableview-jumping-to-top-on-endupdates-while-typing-inside-a-cell-on-ios-8-aut/38582892#38582892) – Manoj Jul 27 '16 at 03:51
  • The below link may help you: http://stackoverflow.com/questions/28917923/uitableview-jumping-to-top-on-endupdates-while-typing-inside-a-cell-on-ios-8-aut/38582892#38582892 – Manoj Jul 27 '16 at 05:53

4 Answers4

66

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)
}
Community
  • 1
  • 1
Kárpáti András
  • 1,221
  • 1
  • 16
  • 35
12

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.

Matic Oblak
  • 16,318
  • 3
  • 24
  • 43
  • The simplest solution! Thanks! – Bonan Dec 18 '18 at 22:41
  • 1
    As I can not post a new answer and this really is no duplicate question, here is my solution in a comment to the best answer and analysis of this question: ```func tableView(_ tableView: UITableView, estimatedHeightForHeaderInSection section: Int) -> CGFloat { return 100 }``` – thetrutz Oct 19 '19 at 01:44
9

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".

DoesData
  • 6,594
  • 3
  • 39
  • 62
-1

Should be simple:

UITableView inherits from UIScrollView. Before updating, use UIScrollView's method to get the current scroll offset. After the update, apply the offset to the table view.

That's all.

Anticro
  • 685
  • 4
  • 12