David
David

Reputation: 7456

reloadData() of UITableView with Dynamic cell heights causes jumpy scrolling

I feel like this might be a common issue and was wondering if there was any common solution to it.

Basically, my UITableView has dynamic cell heights for every cell. If I am not at the top of the UITableView and I tableView.reloadData(), scrolling up becomes jumpy.

I believe this is due to the fact that because I reloaded data, as I'm scrolling up, the UITableView is recalculating the height for each cell coming into visibility. How do I mitigate that, or how do I only reloadData from a certain IndexPath to the end of the UITableView?

Further, when I do manage to scroll all the way to the top, I can scroll back down and then up, no problem with no jumping. This is most likely because the UITableViewCell heights were already calculated.

Upvotes: 160

Views: 56663

Answers (22)

Dmitry
Dmitry

Reputation: 3089

For me the working solution is

UIView.setAnimationsEnabled(false)
    tableView.performBatchUpdates { [weak self] in
    self?.tableView.reloadRows(at: [indexPath], with: .none)
} completion: { [weak self] _ in
    UIView.setAnimationsEnabled(true)
    self?.tableView.scrollToRow(at: indexPath, at: .top, animated: true) // remove if you don't need to scroll
}

I have expandable cells.

Upvotes: 1

rastislv
rastislv

Reputation: 320

I use more ways how to fix it:

For view controller:

var cellHeights: [IndexPath : CGFloat] = [:]


func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
    cellHeights[indexPath] = cell.frame.size.height
}

func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
    return cellHeights[indexPath] ?? 70.0 
}

as the extension for UITableView

extension UITableView {
  func reloadSectionWithoutAnimation(section: Int) {
      UIView.performWithoutAnimation {
          let offset = self.contentOffset
          self.reloadSections(IndexSet(integer: section), with: .none)
          self.contentOffset = offset
      }
  }
}

The result is

tableView.reloadSectionWithoutAnimation(section: indexPath.section)

Upvotes: 13

Prachi Bile
Prachi Bile

Reputation: 1

For me, it worked with "heightForRowAt"

extension APICallURLSessionViewController: UITableViewDelegate {

func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
    print("Inside heightForRowAt")
    return 130.50
}
}

Upvotes: 0

Michael
Michael

Reputation: 1

Actually I found if you use reloadRows causing a jump problem. Then you should try to use reloadSections like this:

UIView.performWithoutAnimation {
    tableView.reloadSections(NSIndexSet(index: indexPath.section) as IndexSet, with: .none)
}

Upvotes: -1

ShaileshAher
ShaileshAher

Reputation: 823

One of the approach to solve this problem that I found is

CATransaction.begin()
UIView.setAnimationsEnabled(false)
CATransaction.setCompletionBlock {
   UIView.setAnimationsEnabled(true)
}
tableView.reloadSections([indexPath.section], with: .none)
CATransaction.commit()

Upvotes: 2

Igor
Igor

Reputation: 12303

To prevent jumping you should save heights of cells when they loads and give exact value in tableView:estimatedHeightForRowAtIndexPath:

Swift:

var cellHeights = [IndexPath: CGFloat]()

func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
    cellHeights[indexPath] = cell.frame.size.height
}

func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
    return cellHeights[indexPath] ?? UITableView.automaticDimension
}

Objective C:

// declare cellHeightsDictionary
NSMutableDictionary *cellHeightsDictionary = @{}.mutableCopy;

// declare table dynamic row height and create correct constraints in cells
tableView.rowHeight = UITableViewAutomaticDimension;

// save height
- (void)tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath {
    [cellHeightsDictionary setObject:@(cell.frame.size.height) forKey:indexPath];
}

// give exact height value
- (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath {
    NSNumber *height = [cellHeightsDictionary objectForKey:indexPath];
    if (height) return height.doubleValue;
    return UITableViewAutomaticDimension;
}

Upvotes: 258

Kiran Jasvanee
Kiran Jasvanee

Reputation: 6564

@Igor answer is working fine in this case, Swift-4 code of it.

// declaration & initialization  
var cellHeightsDictionary: [IndexPath: CGFloat] = [:]  

in following methods of UITableViewDelegate

func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
  // print("Cell height: \(cell.frame.size.height)")
  self.cellHeightsDictionary[indexPath] = cell.frame.size.height
}

func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
  if let height =  self.cellHeightsDictionary[indexPath] {
    return height
  }
  return UITableView.automaticDimension
}

Upvotes: 29

Burcu Kutluay
Burcu Kutluay

Reputation: 610

I had the same issue. I had pagination and reloading data without animation but it did not help the scroll to prevent jumping. I have different size of IPhones, the scroll was not jumpy on iphone8 but it was jumpy on iphone7+

I applied following changes on viewDidLoad function:

    self.myTableView.estimatedRowHeight = 0.0
    self.myTableView.estimatedSectionFooterHeight = 0
    self.myTableView.estimatedSectionHeaderHeight = 0

and my problem solved. I hope it helps you too.

Upvotes: 0

Gobe
Gobe

Reputation: 2717

I had this jumping behavior and I initially was able to mitigate it by setting the exact estimated header height (because I only had 1 possible header view), however the jumps then started to happen inside the headers specifically, not affecting the whole table anymore.

Following the answers here, I had the clue that it was related to animations, so I found that the table view was inside a stack view, and sometimes we'd call stackView.layoutIfNeeded() inside an animation block. My final solution was to make sure this call doesn't happen unless "really" needed, because layout "if needed" had visual behaviors in that context even when "not needed".

Upvotes: 0

Vid
Vid

Reputation: 29

You can use the following in ViewDidLoad()

tableView.estimatedRowHeight = 0     // if have just tableViewCells <br/>

// use this if you have tableview Header/footer <br/>
tableView.estimatedSectionFooterHeight = 0 <br/>
tableView.estimatedSectionHeaderHeight = 0

Upvotes: 0

Flappy
Flappy

Reputation: 867

Overriding the estimatedHeightForRowAtIndexPath method with an high value, for example 300f

This should fix the problem :)

Upvotes: 4

Srujan Simha Adicharla
Srujan Simha Adicharla

Reputation: 3723

I have tried all the workarounds above, but nothing worked.

After spending hours and going through all the possible frustrations, figured out a way to fix this. This solution is a life savior! Worked like a charm!

Swift 4

let lastContentOffset = tableView.contentOffset
tableView.beginUpdates()
tableView.endUpdates()
tableView.layer.removeAllAnimations()
tableView.setContentOffset(lastContentOffset, animated: false)

I added it as an extension, to make the code look cleaner and avoid writing all these lines every time I want to reload.

extension UITableView {

    func reloadWithoutAnimation() {
        let lastScrollOffset = contentOffset
        beginUpdates()
        endUpdates()
        layer.removeAllAnimations()
        setContentOffset(lastScrollOffset, animated: false)
    }
}

finally ..

tableView.reloadWithoutAnimation()

OR you could actually add these line in your UITableViewCell awakeFromNib() method

layer.shouldRasterize = true
layer.rasterizationScale = UIScreen.main.scale

and do normal reloadData()

Upvotes: 21

Krishna Kishore
Krishna Kishore

Reputation: 934

The jump is because of a bad estimated height. The more the estimatedRowHeight differs from the actual height the more the table may jump when it is reloaded especially the further down it has been scrolled. This is because the table's estimated size radically differs from its actual size, forcing the table to adjust its content size and offset. So the estimated height shouldn't be a random value but close to what you think the height is going to be. I have also experienced when i set UITableViewAutomaticDimension if your cells are same type then

func viewDidLoad() {
     super.viewDidLoad()
     tableView.estimatedRowHeight = 100//close to your cell height
}

if you have variety of cells in different sections then I think the better place is

func tableView(tableView: UITableView, estimatedHeightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
     //return different sizes for different cells if you need to
     return 100
}

Upvotes: 42

sabiland
sabiland

Reputation: 2614

I have 2 different cell heights.

func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        let cellHeight = CGFloat(checkIsCleanResultSection(index: indexPath.row) ? 130 : 160)
        return Helper.makeDeviceSpecificCommonSize(cellHeight)
    }

After I added estimatedHeightForRowAt, there was no more jumping.

func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
    let cellHeight = CGFloat(checkIsCleanResultSection(index: indexPath.row) ? 130 : 160)
    return Helper.makeDeviceSpecificCommonSize(cellHeight)
}

Upvotes: 1

Michael Colonna
Michael Colonna

Reputation: 81

None of these solutions worked for me. Here's what I did with Swift 4 & Xcode 10.1...

In viewDidLoad(), declare table dynamic row height and create correct constraints in cells...

tableView.rowHeight = UITableView.automaticDimension

Also in viewDidLoad(), register all your tableView cell nibs to tableview like this:

tableView.register(UINib(nibName: "YourTableViewCell", bundle: nil), forCellReuseIdentifier: "YourTableViewCell")
tableView.register(UINib(nibName: "YourSecondTableViewCell", bundle: nil), forCellReuseIdentifier: "YourSecondTableViewCell")
tableView.register(UINib(nibName: "YourThirdTableViewCell", bundle: nil), forCellReuseIdentifier: "YourThirdTableViewCell")

In tableView heightForRowAt, return height equal to each cell's height at indexPath.row...

func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {

    if indexPath.row == 0 {
        let cell = Bundle.main.loadNibNamed("YourTableViewCell", owner: self, options: nil)?.first as! YourTableViewCell
        return cell.layer.frame.height
    } else if indexPath.row == 1 {
        let cell = Bundle.main.loadNibNamed("YourSecondTableViewCell", owner: self, options: nil)?.first as! YourSecondTableViewCell
        return cell.layer.frame.height
    } else {
        let cell = Bundle.main.loadNibNamed("YourThirdTableViewCell", owner: self, options: nil)?.first as! YourThirdTableViewCell
        return cell.layer.frame.height
    } 

}

Now give an estimated row height for each cell in tableView estimatedHeightForRowAt. Be accurate as you can...

func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {

    if indexPath.row == 0 {
        return 400 // or whatever YourTableViewCell's height is
    } else if indexPath.row == 1 {
        return 231 // or whatever YourSecondTableViewCell's height is
    } else {
        return 216 // or whatever YourThirdTableViewCell's height is
    } 

}

That should work...

I didn't need to save and set contentOffset when calling tableView.reloadData()

Upvotes: 1

Dmytro Brovkin
Dmytro Brovkin

Reputation: 67

This one worked for me in Swift4:

extension UITableView {

    func reloadWithoutAnimation() {
        let lastScrollOffset = contentOffset
        reloadData()
        layoutIfNeeded()
        setContentOffset(lastScrollOffset, animated: false)
    }
}

Upvotes: 2

mfaani
mfaani

Reputation: 36427

There is a bug which I believe was introduced in iOS11.

That is when you do a reload the tableView contentOffSet gets unexpectedly altered. In fact contentOffset should not change after a reload. It tends to happen due to miscalculations of UITableViewAutomaticDimension

You have to save your contentOffSet and set it back to your saved value after your reload is finished.

func reloadTableOnMain(with offset: CGPoint = CGPoint.zero){

    DispatchQueue.main.async { [weak self] () in

        self?.tableView.reloadData()
        self?.tableView.layoutIfNeeded()
        self?.tableView.contentOffset = offset
    }
}

How you use it?

someFunctionThatMakesChangesToYourDatasource()
let offset = tableview.contentOffset
reloadTableOnMain(with: offset)

This answer was derived from here

Upvotes: 2

osku
osku

Reputation: 315

Here's a bit shorter version:

func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
    return self.cellHeightsDictionary[indexPath] ?? UITableViewAutomaticDimension
}

Upvotes: 3

Casey Wagner
Casey Wagner

Reputation: 1528

Swift 3 version of accepted answer.

var cellHeights: [IndexPath : CGFloat] = [:]


func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
    cellHeights[indexPath] = cell.frame.size.height
}

func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
    return cellHeights[indexPath] ?? 70.0 
}

Upvotes: 112

MarcWan
MarcWan

Reputation: 2973

I ran into this today and observed:

  1. It's iOS 8 only, indeed.
  2. Overridding cellForRowAtIndexPath doesn't help.

The fix was actually pretty simple:

Override estimatedHeightForRowAtIndexPath and make sure it returns the correct values.

With this, all weird jittering and jumping around in my UITableViews has stopped.

NOTE: I actually know the size of my cells. There are only two possible values. If your cells are truly variable-sized, then you might want to cache the cell.bounds.size.height from tableView:willDisplayCell:forRowAtIndexPath:

Upvotes: 11

CrimeZone
CrimeZone

Reputation: 230

Try to call cell.layoutSubviews() before returning cell in func cellForRowAtIndexPath(_ indexPath: NSIndexPath) -> UITableViewCell?. It's known bug in iOS8.

Upvotes: 0

Lyndsey Scott
Lyndsey Scott

Reputation: 37300

You can in fact reload only certain rows by using reloadRowsAtIndexPaths, ex:

tableView.reloadRowsAtIndexPaths(indexPathArray, withRowAnimation: UITableViewRowAnimation.None)

But, in general, you could also animate table cell height changes like so:

tableView.beginUpdates()
tableView.endUpdates()

Upvotes: 9

Related Questions