15

My question essentially boils down to the best way to support dynamic heights of UILabel's (and I suppose other elements) in a UITableCell, and also correctly resize the label width/height and cell heights when rotating.

I'm aware of how to get the expected height of UILabels, and size the heights accordingly, but things seem to get pretty tricky when you support landscape orientation as well.

I'm thinking this might go the route of layoutSubviews, but I'm not sure how you would combine that with the table needing to calculate the height of cells. Another interesting post sets the frame manually on init to make sure they are doing calculations off a known quantity, but that only addresses part of the issue.

So here's an image of what I'm dealing with, with red arrows pointing to the dynamic height labels, and blue arrows pointing towards the cells that will change height.

Dynamic UILabel/Cell Height/Width

I've managed to get it working correctly, but not sure if its the correct method.

Here's a few of the things I learned:

  • The cell.frame in cellForRowAtIndexPath always gives its size in portrait mode. (ie. for the iphone app, it always reports 320).
  • Storing a prototype cell for reference in a property has the same issue, always in portrait mode
  • autoresizing masks for the width of labels seem to be useless in this use case, and in fact cause issues with the calculated height on rotation
  • The tableView.frame in cellForRowAtIndexPath gives its size in the correct orientation
  • You need to call [tableView reloadData] on rotation. I couldn't find any other way to update the cell and label heights

Here are the steps I took to get this working for me:

  1. Disable any autoresize masks on UILabels, as they interfere with getting the right label height for some reason
  2. On viewDidLoad I grab a reference to each label font, and a float for the label width percentage compared to the tableView in portrait

    CWProductDetailDescriptionCell *cell = [self.tableView dequeueReusableCellWithIdentifier:@"DescriptionCell"];
    self.descriptionLabelWidthPercentage = cell.descriptionLabel.frame.size.width / 320;
    self.descriptionLabelFont = cell.descriptionLabel.font;
    
  3. In heightForRowAtIndexPath I calculate the cell height using the tableView width and the percentage I already grabbed:

    case TableSectionDescription:
        CGFloat labelWidth = self.tableView.frame.size.width * self.descriptionLabelWidthPercentage;
        CGSize newLabelFrameSize = [self sizeForString:self.product.descriptionText WithConstraint:CGSizeMake(labelWidth, MAXFLOAT) AndFont:self.descriptionLabelFont];
        return newLabelFrameSize.height + kTextCellPadding;
    
  4. In cellForRowAtIndexPath I calculate the frame for the label frame and update it

    cell = [tableView dequeueReusableCellWithIdentifier:@"DescriptionCell"];
    ((CWProductDetailDescriptionCell *)cell).descriptionLabel.text = self.product.descriptionText;
    CGRect oldFrame = ((CWProductDetailDescriptionCell *)cell).descriptionLabel.frame;
    CGFloat labelWidth = self.tableView.frame.size.width * self.descriptionLabelWidthPercentage;
    CGSize newLabelFrameSize = [self sizeForString:self.product.descriptionText WithConstraint:CGSizeMake(labelWidth, MAXFLOAT) AndFont:((CWProductDetailDescriptionCell *)cell).descriptionLabel.font];
    ((CWProductDetailDescriptionCell *)cell).descriptionLabel.frame = CGRectMake(oldFrame.origin.x, oldFrame.origin.y, labelWidth, newLabelFrameSize.height);
    
  5. In didRotateFromInterfaceOrientation you need to [self.tableView reloadData]

Problems with this method:

  1. You can't leverage the autoresizing masks and margins you might set in IB
    • those don't seem to fire until after you've returned the cell in cellForRowAtIndexPath (not sure if this is exactly true)
    • they mess up the height calculation somehow, and your labels end up taller than desired in landscape.
  2. If your label's width isn't an equal percentage in portrait and landscape, you'll need to know both percentages
  3. You need to know/calculate your labels width percentages. Ideally you would be able to calculate these on the fly after an auto-resizing mask has done its thing.
  4. It just feels clumsy. I have a feeling I'm going to run into more headaches in editing mode with indenting.

So. What is the right way to do this? I've seen lots of SO questions, but none quite address this exactly.

Community
  • 1
  • 1
Bob Spryn
  • 17,742
  • 12
  • 68
  • 91
  • Its BS apple makes this such a problem – Chet Aug 05 '14 at 00:56
  • I'm running into these problems for a purely-portrait view. Using Dynamic Prototypes from a Storyboard with dynamic heights (to accommodate various height comments, for instance) is resulting in the views stretching beyond what I tell it in heightForRowAtIndexPath. Bob Spryn's answer below roughly works for me, provided I disable Autoresizing and layout in willDisplayCell. Tempted to file a bug, but not really sure how to fully explain it. – owenfi Nov 24 '14 at 11:31

3 Answers3

12

Apparently it took writing out my giant question and taking a break from the computer to figure it out.

So here's the solution:

The main steps to dynamically calculate UILabel heights on the fly, and take advantage of autoresizing masks:

  1. Keep a prototype cell for each cell that will need to have its height adjusted
  2. In heightForRowAtIndexPath
    • adjust your reference cell width for the orientation you are in
    • thenyou can then reliably use the contained UILabel frame widths for calculating height
  3. Don't do any frame adjustments in cellForRowAtIndexPath aside from initial setup
    • UITableViewCells perform all their layout stuff right after this is called, so your efforts at best are futile, at worst they screw things up
  4. Do your frame adjustments in willDisplayCell forRowAtIndexPath
    • I'm furious I forgot about this
    • It occurs after all the UITableViewCell internal layout and right before it draws
    • Your labels with autoresizing masks on them will already have correct widths you can use for calculations

In viewDidLoad grab references to cells that will need height adjustments

Note that these are retained/strong(ARC), and never added to the table itself

self.descriptionReferenceCell = [self.tableView dequeueReusableCellWithIdentifier:@"DescriptionCell"];
self.attributeReferenceCell = [self.tableView dequeueReusableCellWithIdentifier:@"AttributeCell"];

In heightForRowAtIndexPath do calculation for variable height cells

I first update the width of my reference cell so that it lays out its subviews (UILabels), which I can then use for calculations. Then I use the updated label width to determine the height of my labels, and add any necessary cell padding to calculate my cell height. (Remember these calculations are only necessary if your cell changes heights along with your label).

UIDeviceOrientation orientation = [[UIDevice currentDevice] orientation];
float width = UIDeviceOrientationIsPortrait(orientation) ? 320 : 480;

case TableSectionDescription:
    CGRect oldFrame = self.descriptionReferenceCell.frame;
    self.descriptionReferenceCell.frame = CGRectMake(oldFrame.origin.x, oldFrame.origin.y, width, oldFrame.size.height);
    CGFloat referenceWidth = self.descriptionReferenceCell.descriptionLabel.frame.size.width;
    CGSize newLabelFrameSize = [self sizeForString:self.product.descriptionText WithConstraint:CGSizeMake(referenceWidth, MAXFLOAT) AndFont:self.descriptionReferenceCell.descriptionLabel.font];
    return newLabelFrameSize.height + kTextCellPadding;

In cellForRowAtIndexPath Don't do any dynamic layout related updates relying on orientation

In willDisplayCell forRowAtIndexPath Update the label heights themselves

Since the table cells have performed layoutSubviews, the labels already have the correct widths, which I use to calculate the exact height of the labels. I can safely update my label heights.

case TableSectionDescription:
    CGRect oldFrame = ((CWProductDetailDescriptionCell *)cell).descriptionLabel.frame;
    CGSize newLabelFrameSize = [self sizeForString:self.product.descriptionText WithConstraint:CGSizeMake(oldFrame.size.width, MAXFLOAT) AndFont:((CWProductDetailDescriptionCell *)cell).descriptionLabel.font];
    ((CWProductDetailDescriptionCell *)cell).descriptionLabel.frame = CGRectMake(oldFrame.origin.x, oldFrame.origin.y, oldFrame.size.width, newLabelFrameSize.height);
    break;

Reload Data on rotate

Your visible cells won't update their heights and labels if you don't reload data.

-(void)didRotateFromInterfaceOrientation:(UIInterfaceOrientation)fromInterfaceOrientation{
    [self.tableView reloadData];
}

The only other thing to point out is that I'm using a helper class for calculating the label height that just wraps the basic call. Really its not saving much of any code anymore (I used to have other stuff in there), so just use the normal method straight off your string.

- (CGSize) sizeForString:(NSString*)string WithConstraint:(CGSize)constraint AndFont:(UIFont *)font {
    CGSize labelSize = [string sizeWithFont:font
                          constrainedToSize:constraint 
                              lineBreakMode:UILineBreakModeWordWrap];
    return labelSize;
}
Community
  • 1
  • 1
Bob Spryn
  • 17,742
  • 12
  • 68
  • 91
  • I may have to do a little more work to support the editing mode. I'll add to this once I do that. – Bob Spryn Apr 20 '12 at 04:29
  • BTW, do you think you really need reloadData in didRotateFromInterfaceOrientation? Worst case, you could just adjust the frames (a la something like what you have in willDisplayCell), rather than calling reload, which re-invokes cellForRowAtIndexPath (i.e recreates/requeues all of the visible cells). Or, now that you pointed out to me that you can successfully use the auto resizing masks in willDisplayCell, I find that I no longer need to do anything with didRotateFromInterfaceOrientation anymore (as long as your heightForRowAtIndexPath calculates your variable heights correctly). – Rob Apr 20 '12 at 07:20
  • Yes it appears I do. While the cell heights may be updated correctly automatically, I need to resize my label heights. So its either reload the data, or have the cells themselves be smart enough (I think by overriding layoutSubviews) to update their label heights. – Bob Spryn Apr 20 '12 at 19:10
  • 1
    FYI I've found that overriding `layoutSubviews` in the cell is the most effective way to resize the labels themselves. If you place your changes after [super layoutSubviews] all the parent resizing has already taken place and you can do whatever you need. – Bob Spryn May 02 '12 at 23:09
  • Very thorough. Thanks. However, creating strong references to cells undermines the system's cell recycling strategy. Overriding layoutSubviews is typically the advised approach. – stephen May 25 '13 at 16:31
  • @stephen Those are just reference cells, they aren't used in the actual table. Overriding layoutSubviews is what I do most of the time these days. – Bob Spryn Sep 26 '13 at 19:57
0

I agree with most of your observations. A couple of thoughts, though:

You say:

The cell.frame in cellForRowAtIndexPath always gives its size in portrait mode. (ie. for the iphone app, it always reports 320).

This is not my experience. In my tableView:cellForRowAtIndexPath I just put in a

NSLog(@"Cell frame.size.width=%f", cell.frame.size.width);

And it's reporting 320 or 480 depending upon orientation. So, I'm not seeing any benefit by moving my frame calculation logic into willDisplayCell.

You say:

In heightForRowAtIndexPath I calculate the cell height using the tableView width and the percentage I already grabbed:

Yeah, you can do that. If your descriptions on the right side of the cell are likely to be really long, you might want to keep label on the left side of the cell a fixed width and take advantage of the wider screen to really maximize the long description that you have on the right side of the cell. But if you frequently have shorter descriptions on the right side, realigning based upon the target percentage of the width of the screen is a good idea. Personally, rather than going with converting pixels based upon percentages, I might just used fixed ratios (e.g. label on the left always takes 33% of the width, label on the right always takes 66% of the cell, and it might simplify the percentage logic, though it's functionally equivalent).

You say:

You can't leverage the autoresizing masks and margins you might set in IB

Yeah, I had never tried it (before now and I see the same behavior you describe), but even if it did work, it would be a bad idea, because you really need to recalculate height based upon the word wrapping you're using, so while it might get you close, you'd still need to recalculate heights anyway to get it right anyway. (Frankly, I just wish I could say "autoresize height based upon the label's font and width and text, but that just doesn't exist.) Still, I agree with you that it's weird that it doesn't work better. Autoresize should handle this more gracefully.

You said:

Your visible cells won't update their heights and labels if you don't reload data.

You then suggested invoking reloadData. But isn't didRotateFromInterfaceOrientation too late, because I'm seeing my portrait UILabels flash up right after it goes to landscape, but before the landscape redrawing invoked here takes place. I don't want to subclass UITableViewCell and override drawRect, but I'm not sure how else to re-layout my UILabel frames prior to them being drawn again in landscape. And my initial attempt to move this to willRotateToInterfaceOrientation didn't work, but maybe I didn't do something right there.

Rob
  • 415,655
  • 72
  • 787
  • 1,044
  • It curious that the recalculation of the cell height is taking place as you rotate, but the willDisplay cell is not. Seems like I'm missing something ... seems like I should be able to adjusting my UILabel frames at the same time that the tableview cell heights are be requeried (but without invoking a reloadData), but I don't see how. – Rob Apr 20 '12 at 05:23
  • You cannot. A couple things on what you've mentioned. cellForRowAtIndexPath cell.frame.size may give you the correct width for cells created after the rotation, but those currently being displayed may not yet. Even if it does in fact give you the correct width, since it doesn't layout its contents (layoutSubviews, autoresize, etc) until after the cellForRowAtIndexPath call, you don't want to do your layout there. This is from the documentation. – Bob Spryn Apr 20 '12 at 06:15
  • Also, you wouldn't want to do any calculations in willRotateToInterfaceOrientation, because they would still be based off of the current orientation and sizing. Check out my answer above, it works extremely well and allows me to use IB and have flexible heights and widths. I think the alternative to my solution is to override layoutSubviews in your cell subclass, and handle adjust frames there. – Bob Spryn Apr 20 '12 at 06:18
  • I'm surprised by the comment about not laying out the views in cellForRowAtIndexPath, because that's what the Table View Programming Guide does, it's what I've always done for custom cells, and it works. Can you point us to where in the documentation it says not to do that? – Rob Apr 20 '12 at 06:45
  • With a little experimentation, I have discovered that setting the auto resize masks in `willDisplayCell` definitely eliminates that resizing bug that you mention in your original question. – Rob Apr 20 '12 at 07:11
  • Yea my comment may not be entirely correct. From the docs for willDisplayCell 'A table view sends this message to its delegate just before it uses cell to draw a row, thereby permitting the delegate to customize the cell object before it is displayed.' So I believe this is more in regards to anything you need to override that is setup after cellForRowAtIndexPath is called. I think this includes anything you need to have happen after resizing/layoutsubviews is done. – Bob Spryn Apr 20 '12 at 07:12
  • If you notice in the companion guide, when they setup subviews in cellForRowAtIndexPath they aren't using a different frame depending on orientation, so they must be relying on the fact that autoresizing occurs after the cell is returned. They even set the autoresizing masks on the subviews there. – Bob Spryn Apr 20 '12 at 07:13
  • So I think the reason I need to use willDisplayCell is basically because I need to know the actual width after resizing for use in my calculations. Also if I try to dynamically change the existing frame before autoresizing takes place, I'm asking for trouble. Looks like SO is complaining about extended conversation. Move to chat if you like. – Bob Spryn Apr 20 '12 at 07:15
0

Thanks, this was very helpful. I didn't want to hard-code in any widths, though. I spent a good while looking at this problem and here's what I found.

Basically, there's a chicken-and-egg problem, in that the table view needs to know the heights of the cells to do its own layout, but the cells need to be configured for the geometry of the containing table view before their heights can be determined.

One way to break this circular dependency is to hard-code the width to be 320 or 480, which is suggested elsewhere on this page. I didn't like the idea of hard-coding sizes, though.

Another idea is to calculate all the heights up-front using a prototype cell. I tried this, but still ran into miscalculated heights because creating a UITableViewCell only leaves you with a cell that's not configured for the actual style and size of the table view. Apparently there's no way manually to configure a cell for a specific table view; you have to let UITableView do it.

The strategy I settled upon is to let the table view do everything as it would normally, which includes creating and configuring its own cells, then once all the cells are set up, calculate the actual row heights and refresh the table view. The downside is that the table view is loaded twice (once with default heights and again with the proper heights), but the upside is that this should be a fairly clean and robust solution.


This sample assumes that you have an instance variable _cellHeights which is an array of arrays that hold the calculated row heights per table view section:

@interface MyViewController () {
    NSArray *_cellHeights; // array of arrays (for each table view section) of numbers (row heights)
}

Write a method to calculate all the cell heights based on the current cell contents:

- (void)_recalculateCellHeights
{
    NSMutableArray *cellHeights = [[NSMutableArray alloc] init];

    for (NSUInteger section=0; section<[self.tableView numberOfSections]; section++) {
        NSMutableArray *sectionCellHeights = [[NSMutableArray alloc] init];

        for (NSUInteger row=0; row<[self.tableView numberOfRowsInSection:section]; row++) {
            NSIndexPath *indexPath = [NSIndexPath indexPathForRow:row inSection:section];
            UITableViewCell *cell = [self.tableView cellForRowAtIndexPath:indexPath];

            // Set cell's width to match table view's width
            CGRect cellFrame = cell.frame;
            cellFrame.size.width = CGRectGetWidth(self.tableView.frame);
            cell.frame = cellFrame;

            CGFloat cellHeight = self.tableView.rowHeight; // default

            if (cell.textLabel.numberOfLines == 0 || (cell.detailTextLabel && cell.detailTextLabel.numberOfLines == 0)) {
                [cell layoutIfNeeded]; // this is necessary on iOS 7 to give the cell's text labels non-zero frames

                // Calculate the height of the cell based on the text
                [cell.textLabel sizeToFit];
                [cell.detailTextLabel sizeToFit];

                cellHeight = CGRectGetHeight(cell.textLabel.frame) + CGRectGetHeight(cell.detailTextLabel.frame);

                // This is the best I can come up with for this :(
                if (self.tableView.separatorStyle != UITableViewCellSeparatorStyleNone) {
                    cellHeight += 1.0;
                }
            }

            [sectionCellHeights addObject:[NSNumber numberWithFloat:cellHeight]];
        }

        [cellHeights addObject:sectionCellHeights];
    }

    _cellHeights = cellHeights;
}

In -tableView:cellForRowAtIndexPath:, create a cell as normal and fill out the textLabel, without performing any layout (as Bob mentioned elsewhere on this page: "UITableViewCells perform all their layout stuff right after this is called, so your efforts at best are futile, at worst they screw things up"). The important thing is to set the label's numberOfLines property to 0 to allow for dynamic height.

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
    static NSString *CellIdentifier = @"Cell";

    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:CellIdentifier];
    if (cell == nil) {
        cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:CellIdentifier];

        // Assign any fonts or other adjustments which may affect height here

        // Enable multiple line support so textLabel can grow vertically
        cell.textLabel.numberOfLines = 0;
    }

    // Configure the cell...
    cell.textLabel.text = [self.texts objectAtIndex:indexPath.section];

    return cell;
}

In -tableView:heightForRowAtIndexPath:, return the pre-calculated cell heights:

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath
{
    if (_cellHeights) {
        NSArray *sectionCellHeights = [_cellHeights objectAtIndex:indexPath.section];
        return [[sectionCellHeights objectAtIndex:indexPath.row] floatValue];
    }

    return self.tableView.rowHeight; // default
}

In -viewDidAppear:, trigger recalculation of the cell heights and refresh the table. Note that this must happen at -viewDidAppear: time, not -viewWillAppear: time, because at -viewWillAppear: time, the cells are not yet configured for the geometry of the table view.

- (void)viewDidAppear:(BOOL)animated
{
    [super viewDidAppear:animated];

    [self _recalculateCellHeights];
    [self.tableView reloadData];
}

In -didRotateFromInterfaceOrientation:, do the same thing:

- (void)didRotateFromInterfaceOrientation:(UIInterfaceOrientation)fromInterfaceOrientation
{
    [self _recalculateCellHeights];
    [self.tableView reloadData];
}

It is not necessary to do anything special in -tableView:willDisplayCell:forRowAtIndexPath:.

jrc
  • 20,354
  • 10
  • 69
  • 64