Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

UITableView layout messing up on push segue and return. (iOS 8, Xcode beta 5, Swift)

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): http://imgur.com/a/gY87E

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!

like image 340
matthew.kempson Avatar asked Aug 09 '14 17:08

matthew.kempson


4 Answers

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];
like image 83
Steven Fisher Avatar answered Oct 19 '22 17:10

Steven Fisher


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];
}
like image 27
Kai Burghardt Avatar answered Oct 19 '22 16:10

Kai Burghardt


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.

like image 8
Max MacLeod Avatar answered Oct 19 '22 16:10

Max MacLeod


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.

like image 7
Imanou Petit Avatar answered Oct 19 '22 15:10

Imanou Petit