Why does auto layout in table view cells cause the table's content size to be incorrectly changed?

4

I'm using Xcode 4.6.1 and I have an iOS 6.1 app.

It has a table view that drills down into another table view. The second table view uses custom cells to display its data. However, there is a problem with the second table view: its content size is usually wrong (specifically the height, which is either too tall or too short). This either results in there being extra space at the bottom, or there not being enough space to be able to scroll to the bottom.

The picture below shows four screenshots from a test app that I made which reproduces the problem. I'm going to give a walk-through of the steps leading to my problem.

Four images from my test app

  1. The app loads, and the first table is shown. It has three rows that drill down into the second table.

  2. I select the first row, which is "List: 0 to 10". The navigation controller pushes the second table onto the screen. It has has 11 rows, and (correctly) has the content size for 11 rows.

  3. Then I went back and selected the next row, which is "List: A to P". The second table comes onto the screen again, and it has 16 rows now. However, it only has the content size for 11 rows. The other 5 rows are still down below, but the content size keeps bouncing me back up to the 11th row.

  4. Then I went back and selected the last row, which is "List: a to f". The second table comes onto the screen again, and it has 6 rows. However, it still has the content size for 11 rows. There is too much space at the bottom of the table view, and it shows 5 cells at the bottom with nothing in them.

The pattern here is that the content size stays at whatever it was when it initially loaded. So the first row I select will have the correct content size, but all the other ones will now stay at that size, regardless of how many rows they have. This also means if I had selected "List: a to f" first, which has 6 rows, then all the other ones would have a content size for 6 rows, because it stays at whatever the content size was for the first row I selected.

In my code, I am never changing the content size myself; this is all happening automatically. To figure out when and why the table view was changing its content size, I set an observer on the second table's "contentSize" property. After I did this, I realized that the content size wasn't staying the same, but that it was actually being changed twice when I selected a row: first to the correct size (when the table view reloaded), and then reverted back to the old size (when the table view appeared).

So I'm trying to figure out why it's being reverted back to the old size. To see what was going on behind the scenes, I had it print out a stack trace every time it observed the content size changing.

Below, you can see the console log for the previous steps that a walked you through. However, to conserve space and make it more readable, I replaced the long stack traces with a message like, "(Stack Trace Type A)". I'll show you the details of them later, but for now I'm just calling them Stack Trace Type A, B, or C.

first table: selected row 0 (List: 0 to 10)
second table: viewDidLoad
OBSERVED CHANGE: contentSize.height = 484 (row count = 11)
(Stack Trace Type A)
OBSERVED CHANGE: contentSize.height = 484 (row count = 11)
(Stack Trace Type B)
second table: viewWillAppear
OBSERVED CHANGE: contentSize.height = 484 (row count = 11)
(Stack Trace Type C)
second table: viewDidAppear
*going back to first table*
second table: viewWillDisappear
second table: viewDidDisappear

first table: selected row 1 (List: A to P)
OBSERVED CHANGE: contentSize.height = 704 (row count = 16)
(Stack Trace Type A)
second table: viewWillAppear
OBSERVED CHANGE: contentSize.height = 484 (row count = 16)
(Stack Trace Type C)
second table: viewDidAppear
*going back to first table*
second table: viewWillDisappear
second table: viewDidDisappear

first table: selected row 2 (List: a to f)
OBSERVED CHANGE: contentSize.height = 264 (row count = 6)
(Stack Trace Type A)
second table: viewWillAppear
OBSERVED CHANGE: contentSize.height = 484 (row count = 6)
(Stack Trace Type C)
second table: viewDidAppear
*going back to first table*
second table: viewWillDisappear
second table: viewDidDisappear

As you can see, it's reverting the content size back to the old size. In this case, that would be 484, which is enough space to show 11 cells with a height of 44. If things were working correctly, the observed change that's coupled with Stack Trace Type C would not be happening. So in other words, Type C is what's causing the problem.

Now I'll show you what the stack traces actually look like. Note that the first two (A and B) aren't related to the problem. I'm showing them only so that you can compare/contrast them with Type C, which is the one that seems to cause the problem.

(Stack Trace Type A) When the table view changed its content size due to being reloaded, the stack trace looked like this.

(
    0   CustomTableCellTest   0x00002fe0 -[MasterViewController observeValueForKeyPath:ofObject:change:context:] + 320
    1   Foundation            0x00b0d417 NSKeyValueNotifyObserver + 357
    2   Foundation            0x00b26b24 NSKeyValueDidChange + 456
    3   Foundation            0x00adbd60 -[NSObject(NSKeyValueObserverNotification) didChangeValueForKey:] + 131
    4   Foundation            0x00b48b80 _NSSetSizeValueAndNotify + 185
    5   UIKit                 0x000b57ef -[UITableView(_UITableViewPrivate) _updateContentSize] + 782
    6   UIKit                 0x000c3974 -[UITableView noteNumberOfRowsChanged] + 154
    7   UIKit                 0x000c32dc -[UITableView reloadData] + 769
    8   CustomTableCellTest   0x00004d97 -[MetaMasterViewController tableView:didSelectRowAtIndexPath:] + 567
    9   UIKit                 0x000c7285 -[UITableView _selectRowAtIndexPath:animated:scrollPosition:notifyDelegate:] + 1194
    10  UIKit                 0x000c74ed -[UITableView _userSelectRowAtPendingSelectionIndexPath:] + 201
    11  Foundation            0x00ad15b3 __NSFireDelayedPerform + 380
    12  CoreFoundation        0x01c55376 __CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION__ + 22
    13  CoreFoundation        0x01c54e06 __CFRunLoopDoTimer + 534
    14  CoreFoundation        0x01c3ca82 __CFRunLoopRun + 1810
    15  CoreFoundation        0x01c3bf44 CFRunLoopRunSpecific + 276
    16  CoreFoundation        0x01c3be1b CFRunLoopRunInMode + 123
    17  GraphicsServices      0x01bf07e3 GSEventRunModal + 88
    18  GraphicsServices      0x01bf0668 GSEventRun + 104
    19  UIKit                 0x00017ffc UIApplicationMain + 1211
    20  CustomTableCellTest   0x000021ed main + 141
    21  CustomTableCellTest   0x00002115 start + 53
)

(Stack Trace Type B) This one occurs only the first time the table view appears, so it only happens once. I believe it occurs when the table initially loads its view.

(
    0   CustomTableCellTest   0x00002fe0 -[MasterViewController observeValueForKeyPath:ofObject:change:context:] + 320
    1   Foundation            0x00b0d417 NSKeyValueNotifyObserver + 357
    2   Foundation            0x00b26b24 NSKeyValueDidChange + 456
    3   Foundation            0x00adbd60 -[NSObject(NSKeyValueObserverNotification) didChangeValueForKey:] + 131
    4   Foundation            0x00b48b80 _NSSetSizeValueAndNotify + 185
    5   UIKit                 0x000b57ef -[UITableView(_UITableViewPrivate) _updateContentSize] + 782
    6   UIKit                 0x000cbc8e -[UITableView _rectChangedWithNewSize:oldSize:] + 261
    7   UIKit                 0x000cc231 -[UITableView setFrame:] + 279
    8   UIKit                 0x000f5014 +[UIViewControllerWrapperView wrapperViewForView:frame:] + 448
    9   UIKit                 0x00110e18 -[UINavigationController _startTransition:fromViewController:toViewController:] + 239
    10  UIKit                 0x0011189b -[UINavigationController _startDeferredTransitionIfNeeded:] + 386
    11  UIKit                 0x00111e93 -[UINavigationController pushViewController:transition:forceImmediate:] + 1030
    12  UIKit                 0x00111a88 -[UINavigationController pushViewController:animated:] + 62
    13  CustomTableCellTest   0x00004e21 -[MetaMasterViewController tableView:didSelectRowAtIndexPath:] + 705
    14  UIKit                 0x000c7285 -[UITableView _selectRowAtIndexPath:animated:scrollPosition:notifyDelegate:] + 1194
    15  UIKit                 0x000c74ed -[UITableView _userSelectRowAtPendingSelectionIndexPath:] + 201
    16  Foundation            0x00ad15b3 __NSFireDelayedPerform + 380
    17  CoreFoundation        0x01c55376 __CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION__ + 22
    18  CoreFoundation        0x01c54e06 __CFRunLoopDoTimer + 534
    19  CoreFoundation        0x01c3ca82 __CFRunLoopRun + 1810
    20  CoreFoundation        0x01c3bf44 CFRunLoopRunSpecific + 276
    21  CoreFoundation        0x01c3be1b CFRunLoopRunInMode + 123
    22  GraphicsServices      0x01bf07e3 GSEventRunModal + 88
    23  GraphicsServices      0x01bf0668 GSEventRun + 104
    24  UIKit                 0x00017ffc UIApplicationMain + 1211
    25  CustomTableCellTest   0x000021ed main + 141
    26  CustomTableCellTest   0x00002115 start + 53
)

So Type A and B both occur when things are working correctly. However, when things are not working correctly, I also get this next stack trace.

(Stack Trace Type C) This is the stack trace that occurs when the content size is reverted back to the old size.

(
    0   CustomTableCellTest   0x00003080 -[MasterViewController observeValueForKeyPath:ofObject:change:context:] + 320
    1   Foundation            0x00b0d417 NSKeyValueNotifyObserver + 357
    2   Foundation            0x00b26b24 NSKeyValueDidChange + 456
    3   Foundation            0x00adbd60 -[NSObject(NSKeyValueObserverNotification) didChangeValueForKey:] + 131
    4   Foundation            0x00b48b80 _NSSetSizeValueAndNotify + 185
    5   UIKit                 0x0007c213 -[UIScrollView _resizeWithOldSuperviewSize:] + 161
    6   UIKit                 0x0005cf2a -[UIView(Geometry) resizeWithOldSuperviewSize:] + 72
    7   UIKit                 0x0005bb28 __46-[UIView(Geometry) resizeSubviewsWithOldSize:]_block_invoke_0 + 80
    8   CoreFoundation        0x01cb85a7 __NSArrayChunkIterate + 359
    9   CoreFoundation        0x01c9003f __NSArrayEnumerate + 1023
    10  CoreFoundation        0x01c8fa16 -[NSArray enumerateObjectsWithOptions:usingBlock:] + 102
    11  UIKit                 0x0005babf -[UIView(Geometry) resizeSubviewsWithOldSize:] + 149
    12  UIKit                 0x00557dcc -[UIView(AdditionalLayoutSupport) _is_layout] + 143
    13  UIKit                 0x000607ae -[UIView(Hierarchy) layoutSubviews] + 80
    14  UIKit                 0x000682dd -[UIView(CALayerDelegate) layoutSublayersOfLayer:] + 279
    15  libobjc.A.dylib       0x010e76b0 -[NSObject performSelector:withObject:] + 70
    16  QuartzCore            0x02292fc0 -[CALayer layoutSublayers] + 240
    17  QuartzCore            0x0228733c _ZN2CA5Layer16layout_if_neededEPNS_11TransactionE + 468
    18  QuartzCore            0x02287150 _ZN2CA5Layer28layout_and_display_if_neededEPNS_11TransactionE + 26
    19  QuartzCore            0x022050bc _ZN2CA7Context18commit_transactionEPNS_11TransactionE + 324
    20  QuartzCore            0x02206227 _ZN2CA11Transaction6commitEv + 395
    21  QuartzCore            0x022068e2 _ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv + 96
    22  CoreFoundation        0x01c5eafe __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__ + 30
    23  CoreFoundation        0x01c5ea3d __CFRunLoopDoObservers + 381
    24  CoreFoundation        0x01c3c7c2 __CFRunLoopRun + 1106
    25  CoreFoundation        0x01c3bf44 CFRunLoopRunSpecific + 276
    26  CoreFoundation        0x01c3be1b CFRunLoopRunInMode + 123
    27  GraphicsServices      0x01bf07e3 GSEventRunModal + 88
    28  GraphicsServices      0x01bf0668 GSEventRun + 104
    29  UIKit                 0x00017ffc UIApplicationMain + 1211
    30  CustomTableCellTest   0x000021ed main + 141
    31  CustomTableCellTest   0x00002115 start + 53
)

As you can see, there is some QuartzCore stuff happening that wasn't present in Type A or B. Also, on several of the lines in Type C, there are actually methods being called behind the scenes with the words "OldSize" in their names. So apparently it really is reverting it back the "old size". However, I still don't understand why these QuartzCore and "OldSize" methods are being called in the first place.

I tried looking up "resizeSubviewsWithOldSize", but the only documentation I could find on it was for NSView (which is for OS X apps, not iOS apps which use UIView). This description in the docs didn't really help me understand why it was being called in this case.

However, even though I don't know why it's being called, I have a pretty good idea of when it's being called. With more testing I realized that it reverts to the old content size whenever the table view appears, and sometimes when it disappears.

For example, when I made a modal view cover the screen and then go away, the content size was actually changed 3 times. The log messages looked something like this.

*Modal view entering*
second table: viewWillDisappear
OBSERVED CHANGE: contentSize.height = 484 (row count = 11)
(Stack Trace Type C)
second table: viewDidDisappear

*Modal view exiting*
second table: viewWillAppear
OBSERVED CHANGE: contentSize.height = 484 (row count = 11)
(Stack Trace Type C)
second table: viewDidAppear
OBSERVED CHANGE: contentSize.height = 484 (row count = 11)
(Stack Trace Type C)

So the change isn't related to the table view being reloaded: it has to do with it appearing and disappearing. I'm guessing that views try to layout their subviews every time they appear, so there seems to be a problem with how my custom cells are laid out.

Initially I thought that the problem was with how my custom cells were set up: either in my code or in the xib. This was because the problem disappeared when I didn't use my custom cells. But then one time I turned auto layout off in my custom cell xib, and the problem also disappeared. So apparently the problem has something to do with auto layout.

For the sake of easy debugging, the way I set up the cell in the xib is very simple: the only thing on the cell is a UILabel with a gray background color. All of the auto layout constraints were the default ones made by Interface Builder. You can see what my custom cell xib looks like below.

MasterCell xib

I also tried deleting the UILabel so the cell had nothing on it, and I left auto layout turned on. When I did this, the problem also went away. So I guess there's something wrong with the way the UILabel is set up on the cell. Which is odd since it was set up with those "purple" default constraints by Interface Builder that I can't delete.

After looking around for information regarding auto layout in table view cells, I came across this question. Apparently, Interface Builder doesn't set its constraints to the cell's content view correctly. I wasn't sure if this was related to my problem, but I tried it anyways. I used the solution posted by Adrian, which looked like this.

//  MasterCell.m
//  My custom UITableViewCell subclass

- (void)awakeFromNib {
    [super awakeFromNib];

    for (NSLayoutConstraint *cellConstraint in self.constraints) {

        [self removeConstraint:cellConstraint];

        id firstItem = cellConstraint.firstItem == self ? self.contentView : cellConstraint.firstItem;
        id seccondItem = cellConstraint.secondItem == self ? self.contentView : cellConstraint.secondItem;

        NSLayoutConstraint *contentViewConstraint =
        [NSLayoutConstraint constraintWithItem:firstItem
                                     attribute:cellConstraint.firstAttribute
                                     relatedBy:cellConstraint.relation
                                        toItem:seccondItem
                                     attribute:cellConstraint.secondAttribute
                                    multiplier:cellConstraint.multiplier
                                      constant:cellConstraint.constant];

        [self.contentView addConstraint:contentViewConstraint];
    }
}

However, while it seemed to do what it was intended to do (i.e. change the constraints to be related to the content view instead of the cell itself), this still didn't solve my problem: the content size was still being reverted back to the old size.

So in conclusion, and to clarify this rather long question, this is basically what I'm asking.

Why is it that, when I enable auto layout in my custom table cell xib, that the table view's content size gets incorrectly re-sized back to the "old size" when the table appears on the screen?

cocoa-touch
uitableview
ios6
autolayout
asked on Stack Overflow Mar 23, 2013 by user2129800 • edited May 23, 2017 by Community

1 Answer

1

You are re-using the same instance of a table view controller for the "Master" table, but you are sending it messages in what appears to be the wrong order.

You push the controller like this:

if (!self.masterViewController) {
    self.masterViewController = [[MasterViewController alloc] init];
}

self.masterViewController.objects = self.masterObjects[indexPath.row];

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

If you simply swap around the reloadData and push lines, then your problem disappears.

I'm not entirely sure what is happening here, but I think the problem is that, once the master controller is popped, it doesn't have any frame of reference (pun intended!) to calculate the appropriate content size when you reload it, so it probably isn't caching the content size for this new height.

Then, when the view is pushed, the scroll view suddenly has a context again, it knows it has recalculated and so uses the cached content size. That is the impression I get (as you did) from the stack trace, anyway. The first table you choose is always fine since there isn't an old size available. If you'd made this with storyboards you would be instantiating a new Master controller each time and you'd never have noticed a problem.

As for why autolayout makes any difference, well, again I'm only guessing and hand-waving here, but under autolayout everything happens a lot later on in the view controller lifecycle than it does under explicit layout. If you have autolayout all the way down, then in a view with no superview (and therefore no constraints on its size) autolayout is not able to work out what size things should be. It's probably a bug, but the workaround is trivial enough and as I say, typically when pushing new view controllers onto a navigation stack you'd initialise new ones, and they'd go away when they were popped, so you'd never see this.

answered on Stack Overflow Mar 24, 2013 by jrturton

User contributions licensed under CC BY-SA 3.0