40

tldr; Auto constrains appear to break on push segue and return to view for custom cells

Edit: I have provided a github example project that shows off the error that occurs https://github.com/Matthew-Kempson/TableViewExample.git

I am creating an app which requires the title label of the custom UITableCell to allow for varying lines dependent on the length of the post title. The cells load into the view correctly but if I press on a cell to load the post in a push segue to a view containing a WKWebView you can see, as shown in the screen shot, the cells move immediately to incorrect positions. This is also viewed when loading the view back through the back button of the UINavigationController.

In this particular example I pressed on the very end cell, with the title "Two buddies I took a picture of in Paris", and everything is loaded correctly. Then as shown in the next screenshot the cells all move upwards for unknown reasons in the background of loading the second view. Then when I load the view back you can see the screen has shifted upwards slightly and I cannot actually scroll any lower than is shown. This appears to be random as with other tests when the view loads back there is white space under the bottom cell that does not disappear.

I have also included a picture containing the constraints that the cells has.

Images (I need more reputation to provide images in this question apparently so they are in this imgur album): https://i.stack.imgur.com/NvisX.jpg

My code:

Method in custom cell to allow the cell to resize the view correctly when rotating:

override func layoutSubviews() {
    super.layoutSubviews()

    self.contentView.layoutIfNeeded()

    // Update the label constaints
    self.titleLabel.preferredMaxLayoutWidth = self.titleLabel.frame.width
    self.detailsLabel.preferredMaxLayoutWidth = self.detailsLabel.frame.width
}

Code in tableview

override func viewDidLoad() {
    super.viewDidLoad()

    // Create and register the custom cell
    self.tableView.estimatedRowHeight = 56
    self.tableView.rowHeight = UITableViewAutomaticDimension
}

Code to create the cell

    override func tableView(tableView: UITableView!, cellForRowAtIndexPath indexPath: NSIndexPath!) -> UITableViewCell! {
    if let cell = tableView.dequeueReusableCellWithIdentifier("LinkCell", forIndexPath: indexPath) as? LinkTableViewCell {

        // Retrieve the post and set details
        let link: Link = self.linksArray.objectAtIndex(indexPath.row) as Link

        cell.titleLabel.text = link.title
        cell.scoreLabel.text = "\(link.score)"
        cell.detailsLabel.text = link.stringCreatedTimeIntervalSinceNow() + " ago by " + link.author + " to /r/" + link.subreddit

        return cell
    }

    return nil
}

If you require any more code or information please ask and I shall provide what is necessary

Thanks for your help!

matthew.kempson
  • 1,014
  • 1
  • 12
  • 23
  • 1
    I'm having a similar issue. Were you able to solve the problem? – Jan Aug 27 '14 at 16:33
  • I'm also having the problem, but my constraints are in code. – blork Aug 28 '14 at 07:28
  • You're using auto-sizing table view cells which I've found to be quite buggy in the betas (as to be expected in a beta; auto-sizing collection view cells are even worse). The title of your question says you're using beta5; your example project seems to work quite a bit better in beta6 (although still not perfect). – Mike S Aug 28 '14 at 15:18
  • Check @POB's answer, and yeah I agree that beta 6 improved slightly, still not as expected though – matthew.kempson Aug 28 '14 at 17:53
  • I agree that it's a bug in iOS 8 Beta 5, but I'm not sure how Xcode Beta 6 would have any impact, since it still contains b5 of the SDK. Perhaps the constraint solver in IB may have been improved, but I'm writing my constraints in code. – blork Aug 29 '14 at 08:25
  • 2
    I see the same issue in xCode 6.1. – orkenstein Nov 25 '14 at 13:06
  • I had a call to `self.tableView.reloadData()` in `viewDidAppear()` which caused my `UITableView` to jerk upwards when returning to it from a pushed viewController. Removing the extraneous call to `self.tableView.reloadData()` fixed the jerkiness problem. – Sakiboy Jun 30 '15 at 17:12

7 Answers7

38

This bug is caused by having no tableView:estimatedHeightForRowAtIndexPath: method. It's an optional part of the UITableViewDelegate protocol.

This isn't how it's supposed to work. Apple's documentation says:

Providing an estimate the height of rows can improve the user experience when loading the table view. If the table contains variable height rows, it might be expensive to calculate all their heights and so lead to a longer load time. Using estimation allows you to defer some of the cost of geometry calculation from load time to scrolling time.

So this method is supposed to be optional. You'd think if you skipped it, it would fall back on the accurate tableView:heightForRowAtIndexPath:, right? But if you skip it on iOS 8, you'll get this behaviour.

What seems to be happening? I have no internal knowledge, but it looks like if you do not implement this method, the UITableView will treat that as an estimated row height of 0. It will compensate for this somewhat (and, at least in some cases, complain in the log), but you'll still see an incorrect size. This is quite obviously a bug in UITableView. You see this bug in some of Apple's apps, including something as basic as Settings.

So how do you fix it? Provide the method! Implement tableView: estimatedHeightForRowAtIndexPath:. If you don't have a better (and fast) estimate, just return UITableViewAutomaticDimension. That will fix this bug completely.

Like this:

- (CGFloat)tableView:(UITableView *)tableView
estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath {
    return UITableViewAutomaticDimension;
}

There are potential side effects. You're providing a very rough estimate. If you see consequences from this (possibly cells shifting size as you scroll), you can try to return a more accurate estimate. (Remember, though: estimate.)

That said, this method is not supposed to return a perfect size, just a good enough size. Speed is more important than accuracy. And while I spotted a few scrolling glitches in the Simulator there were none in any of my apps on the actual device, either the iPhone or iPad. (I actually tried writing a more accurate estimate. But it's hard to balance speed and accuracy, and there was simply no observable difference in any of my apps. They all worked exactly as well as just returning UITableViewAutomaticDimension, which was simpler and was enough to fix the bug.)

So I suggest you do not try to do more unless more is required. Doing more if it is not required is more likely to cause bugs than fix them. You could end up returning 0 in some cases, and depending on when you return it that could lead to the original problem reappearing.

The reason Kai's answer above appears to work is that it implements tableView:estimatedHeightForRowAtIndexPath: and thus avoids the assumption of 0. And it does not return 0 when the view is disappearing. That said, Kai's answer is overly complicated, slow, and no more accurate than just returning UITableViewAutomaticDimension. (But, again, thanks Kai. I'd never have figured this out if I hadn't seen your answer and been inspired to pull it apart and figure out why it works.)]

Note that you may also need to force layout of the cell. You'd think iOS would do this automatically when you return the cell, but it doesn't always. (I will edit this once I investigate a bit more to figure out when you need to do this.)

If you need to do this, use this code before return cell;:

[cell.contentView setNeedsLayout];
[cell.contentView layoutIfNeeded];
Steven Fisher
  • 44,462
  • 20
  • 138
  • 192
  • I was noticing the issue on the device as well. Preliminary testing suggests that this resolves the issue for me. Thanks for the answer. – Roderic Campbell Nov 12 '14 at 15:37
  • To get this to work I had to return a value, not UITableViewAutomaticDimension. The value returned had to be slightly larger than my cells maximum height to prevent the cells collapsing on push. – alexkent Jan 28 '15 at 10:50
  • That shouldn't be necessary from the estimate method. Curious what's going on there. – Steven Fisher Jan 29 '15 at 19:40
  • Oh, now I remember. Sigh. Can't believe I forgot about that. Will edit. – Steven Fisher Feb 19 '15 at 20:39
  • 5
    Appreciate the answer, but I still find when tapping on a view to push to a new view controller, the table view's scroll position shifts. Is this supposed to solve that? I tried on my device and scrolling still seems very unreliable with this method. – Doug Smith Mar 06 '15 at 18:07
  • I've found it gets unreliable if the table is tall enough. I don't have a workaround; if you exclude the size estimate, behaviour is very bad in other ways. If you try to do more specific size estimates, it doesn't help scrolling at all. – Steven Fisher Mar 11 '15 at 20:23
  • After much testing, it seems that this answer does not solve the problem. If you have cells of differing heights in your table, you need to do something that involves caching those heights. Of the caching solutions, @Max MacLeod's below is cleaner and definitely works. Apple gives us so many bugs to work around :-( – shmim Mar 24 '15 at 16:36
  • Also, forcing layout of the cell via `setNeedsLayout` or `layoutIfNeeded` will definitely slow your table down a LOT more than merely storing some floats in a dictionary and using them when you need them, which is all the proposed caching solutions do.. – shmim Mar 24 '15 at 17:08
  • I've used this technique in several applications. It definitely works. The real problem with the caching approach is that it is _guaranteed_ to return distractingly incorrect heights to the table view. Apple could do us all a huge favour by just fixing its code here. – Steven Fisher Mar 24 '15 at 17:36
  • Btw, you do _not_ force the layout in the estimate function. :) – Steven Fisher Mar 25 '15 at 16:25
  • Adding the code you mentioned (with just `estimatedHeightForRowAtIndexPath` returning `UITableViewAutomaticDimension`) completely fixed my issue when the view loads each and every time. It used to correct the cell height only when I changed something in the view, but now it's always good. I only have 7 cells in my table though. Before adding the routine, I only had the `tableView.rowHeight = UITableViewAutomaticDimension` in my viewWillAppear routine. – James C Apr 22 '15 at 03:56
  • 1
    Did you set `estimatedRowHeight` property?If yes,then comment it. – tounaobun Dec 03 '15 at 12:26
27

The problem of this behavior is when you push a segue the tableView will call the estimatedHeightForRowAtIndexPath for the visible cells and reset the cell height to a default value. This happens after the viewWillDisappear call. If you come back to TableView all the visible cells are messed up..

I solved this problem with a estimatedCellHeightCache. I simply add this code snipped to the cellForRowAtIndexPath method:

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    ...
    // put estimated cell height in cache if needed
    if (![self isEstimatedRowHeightInCache:indexPath]) {
        CGSize cellSize = [cell systemLayoutSizeFittingSize:CGSizeMake(self.view.frame.size.width, 0) withHorizontalFittingPriority:1000.0 verticalFittingPriority:50.0];
        [self putEstimatedCellHeightToCache:indexPath height:cellSize.height];
    }
    ...
}

Now you have to implement the estimatedHeightForRowAtIndexPath as following:

-(CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath {
    return [self getEstimatedCellHeightFromCache:indexPath defaultHeight:41.5];
}

Configure the Cache

Add this property to your .h file:

@property NSMutableDictionary *estimatedRowHeightCache;

Implement methods to put/get/reset.. the cache:

#pragma mark - estimated height cache methods

// put height to cache
- (void) putEstimatedCellHeightToCache:(NSIndexPath *) indexPath height:(CGFloat) height {
    [self initEstimatedRowHeightCacheIfNeeded];
    [self.estimatedRowHeightCache setValue:[[NSNumber alloc] initWithFloat:height] forKey:[NSString stringWithFormat:@"%d", indexPath.row]];
}

// get height from cache
- (CGFloat) getEstimatedCellHeightFromCache:(NSIndexPath *) indexPath defaultHeight:(CGFloat) defaultHeight {
    [self initEstimatedRowHeightCacheIfNeeded];
    NSNumber *estimatedHeight = [self.estimatedRowHeightCache valueForKey:[NSString stringWithFormat:@"%d", indexPath.row]];
    if (estimatedHeight != nil) {
        //NSLog(@"cached: %f", [estimatedHeight floatValue]);
        return [estimatedHeight floatValue];
    }
    //NSLog(@"not cached: %f", defaultHeight);
    return defaultHeight;
}

// check if height is on cache
- (BOOL) isEstimatedRowHeightInCache:(NSIndexPath *) indexPath {
    if ([self getEstimatedCellHeightFromCache:indexPath defaultHeight:0] > 0) {
        return YES;
    }
    return NO;
}

// init cache
-(void) initEstimatedRowHeightCacheIfNeeded {
    if (self.estimatedRowHeightCache == nil) {
        self.estimatedRowHeightCache = [[NSMutableDictionary alloc] init];
    }
}

// custom [self.tableView reloadData]
-(void) tableViewReloadData {
    // clear cache on reload
    self.estimatedRowHeightCache = [[NSMutableDictionary alloc] init];
    [self.tableView reloadData];
}
Kai Burghardt
  • 1,493
  • 16
  • 16
  • 4
    This is overkill. What matters is that `tableView: estimatedHeightForRowAtIndexPath:` exist; it doesn't matter much what it returns. See my answer below. (But thanks for posting this, because otherwise I never would have figured this out!) :) – Steven Fisher Nov 13 '14 at 00:09
  • 2
    Hey @Kai, your solution was the only thing which fixed our issue with a jumpy scrolling (up) after returning from a segue. We created generalizable solution in a cocoapod inspired from your code here https://github.com/IndieGoGo/IGTableViewEstimatedHeightCache . – Glen T Feb 05 '15 at 19:39
  • 1
    I strongly disagree that this is overkill. Can confirm that using a tableview in pure code (no nibs/storyboard) that the estimatedHeightForRowAtIndexPath delegate method is called when pushing a new view controller on the Navigation Stack in iOS8. HOWEVER, I am NOT seeing this behavior in iOS7. Providing a more refined estimation eliminates the "jerky" behavior reported. – rmigneco Feb 11 '15 at 21:35
  • The cache should probably be keyed on your model objects, rather than on index paths. If you insert a row at index 0, you're gonna end up returning the wrong height for every row in your table – Adlai Holler Apr 01 '15 at 18:10
  • Thank you, thank you, thank you! Was dealing with this issue forever. Even Apple told me they didn't have a solution. – Nic Hubbard Apr 14 '15 at 21:20
  • Definitely not overkill. Returning `UITableViewAutomaticDimension` (which is -1) can result in iOS calculating the table height as shorter than it really is and thus scrolling the table up (to zero if the calculated height is shorter than the table view height). A simpler solution is to return 1000 (or some number greater than the maximum table cell height). `Please Apple give up on that "estimated" rubbish and write an new alternative table class that allows auto-layout heights and hiding rows and sections!` – Matt May 27 '15 at 05:42
8

I had the exact same problem. The table view had several different cell classes, each of which was a different height. Moreover, one of the cells classes had to show additional text, meaning further variation.

Scrolling was perfect in most situations. However, the same problem described in the question manifested. That was, having selected a table cell and presented another view controller, on return to the original table view, the upwards scrolling was extremely jerky.

The first line of investigation was to consider why data was being reloaded at all. Having experimented, I can confirm that on return to the table view, data is reloaded, albeit not using reloadData.

See my comment ios 8 tableview reloads automatically when view appears after pop

With no mechanism to deactivate this behaviour, the next line of approach was to investigate the jerky scrolling.

I came to the conclusion that the estimates returned by estimatedHeightForRowAtIndexPath are an estimated precalculation. Log to console out the estimates and you'll see that the delegate method is queried for every row when the table view first appears. That's before any scrolling.

I quickly discovered that some of the height estimate logic in my code was badly wrong. Resolving this fixed the worst of the jarring.

To achieve perfect scrolling, I took a slightly different approach to the answers above. The heights were cached, but the values used were from the actual heights that would have been captured as the user scrolls downwards:

    var myRowHeightEstimateCache = [String:CGFloat]()

To store:

func tableView(tableView: UITableView, didEndDisplayingCell cell: UITableViewCell, forRowAtIndexPath indexPath: NSIndexPath) {
    myRowHeightEstimateCache["\(indexPath.row)"] = CGRectGetHeight(cell.frame)
}

Using from the cache:

func tableView(tableView: UITableView, estimatedHeightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat
{
    if let height = myRowHeightEstimateCache["\(indexPath.row)"]
    {
        return height
    } 
    else 
    {
    // Not in cache
    ... try to figure out estimate 
    }

Note that in the method above, you will need to return some estimate, as that method will of course be called before didEndDisplayingCell.

My guess is that there is some sort of Apple bug underneath all of this. That's why this issue only manifests in an exit scenario.

Bottom line is that this solution is very similar to those above. However, I avoid any tricky calculations and make use of the UITableViewAutomaticDimension behaviour to just cache the actual row heights displayed using didEndDisplayingCell.

TLDR: work around what's most likely a UIKit defect by caching the actual row heights. Then query your cache as the first option in the estimation method.

Community
  • 1
  • 1
Max MacLeod
  • 26,115
  • 13
  • 104
  • 132
  • Use this with var myRowHeightEstimateCache = [NSIndexPath:CGFloat](), and you get to support sections as well – Arnaud Mar 24 '15 at 17:51
  • 1
    clearly the best solution so far. Let us hope that Apple is fixing this issue in the nearest future. Thank you Max! – f0rz Apr 21 '15 at 20:42
  • 1
    Be sure to remove any extraneous calls to `self.tableView.reloadData()` in the life-cycle methods (i.e `viewWillAppear`, `viewDidAppear`...). I had this same problem and it was a result from an extra unnecessary call to `self.tableView.reloadData()` in `viewDidAppear()`. – Sakiboy Jun 30 '15 at 17:17
7

Well, until it works, you can delete those two line:

self.tableView.estimatedRowHeight = 45
self.tableView.rowHeight = UITableViewAutomaticDimension

And add this method to your viewController:

override func tableView(tableView: UITableView!, heightForRowAtIndexPath indexPath: NSIndexPath!) -> CGFloat {
    let cell = tableView.dequeueReusableCellWithIdentifier("cell") as TableViewCell
    cell.cellLabel.text = self.tableArray[indexPath.row]

    //Leading space to container margin constraint: 0, Trailling space to container margin constraint: 0
    let width = tableView.frame.size.width - 0
    let size = cell.cellLabel.sizeThatFits(CGSizeMake(width, CGFloat(FLT_MAX)))

    //Top space to container margin constraint: 0, Bottom space to container margin constraint: 0, cell line: 1
    let height = size.height + 1

    return (height <= 45) ? 45 : height
}

It worked without any other changes in your test project.

Imanou Petit
  • 89,880
  • 29
  • 256
  • 218
  • 1
    Works perfectly and as expected, I'm sure though that in future Betas that it hopefully will be fixed! – matthew.kempson Aug 28 '14 at 17:52
  • 1
    How would I work out the height of a cell which contains two labels, one above each other. The top cell is resizeable and the bottom single line only. I have attempted but with no success. Thanks @POB – matthew.kempson Aug 28 '14 at 21:42
3

If you have set tableView's estimatedRowHeight property.

tableView.estimatedRowHeight = 100;

Then comment it.

//  tableView.estimatedRowHeight = 100;

It solved the bug which occurs in iOS8.1 for me.

If you really want to keep it,then you could force tableView to reloadData before pushing.

[self.tableView reloadData];
[self.navigationController pushViewController:vc animated:YES];

or do it in viewWillDisappear:.

- (void)viewWillDisappear:(BOOL)animated {
    [super viewWillDisappear:animated];
    [self.tableView reloadData];
}

Hope it helps.

tounaobun
  • 14,570
  • 9
  • 53
  • 75
2

In xcode 6 final for me the workaround does not work. I am using custom cells and dequeing a cell in heightForCell leads to infinity loop. As dequeing a cell calls heightForCell.

And still the bug seems to be present.

Tuan
  • 893
  • 1
  • 9
  • 24
0

If none of the above worked for you (as it happened to me) just check the estimatedRowHeight property from the table view is kind of accurate. I checked I was using 50 pixels when it was actually closer to 150 pixels. Updating this value fixed the issue!

tableView.rowHeight = UITableViewAutomaticDimension
tableView.estimatedRowHeight = tableViewEstimatedRowHeight // This should be accurate.
jomafer
  • 2,655
  • 1
  • 32
  • 47