Ruby on Rails, iOS, Git...

We spend a lot of time thinking about these things. If we have something helpful to share, we'll put it here.

Request a free RaddOnline® proposal.

iPhone SDK: Resizing a UITableViewCell to Hold Variable Amounts of Text, Part 2 of 2

Posted by Tim Stephenson, RaddOnline® on Saturday, March 14, 2009

Completing the UITextView Example

In part one, I demonstrated how to use a UITextView and a UITableViewCell to create a field for users to enter large amounts of text. After the text is saved, I’d like to display that text in a different table. The problem is I don’t know the height of the text. I’ll tackle that problem now.

variabletableviewcell

I’ll be starting with the project that I created in part one. If you want to follow along, here’s the sample project prior to these changes: UITextView

Calculating the Height of the Text in the Field

The first thing that I need to do is determine the height of the text that will be displayed. Luckily, there is a method to do just that in the NSString class.

- (CGSize)sizeWithFont:(UIFont *)font constrainedToSize:(CGSize)size lineBreakMode:(UILineBreakMode)lineBreakMode

As you can see, we need to know the font, the size of the area the text will be constrained to, and the mode for the line breaks. In this case, the font will be a system font. Since I will be doing this repeatedly, and I’d like to use this method without repeating myself, I’ve decided to create a category for NSString. Categories are a convenient way to extend objective-C classes. If your unsure about how they work, here is a good article that may help. Extending Classes In Objective-C with Categories

Create an NSString Category

  1. In Xcode control click on the Classes group under Groups and Files.
  2. Select Add > New File to create a new file.
  3. Select Cocoa Touch Classes, click on NSObject subclass, and click Next. Name the file StringHelper.m. Make sure you have “Also create ‘StringHelper.h’” checked.
  4. Open the StringHelper.h file and change it like this:
#import <UIKit/UIKit.h>

@interface NSString (StringHelper)
  - (CGFloat)RAD_textHeightForSystemFontOfSize:(CGFloat)size;
@end

Notice that I removed the NSObject subclass. The interface defines a new category for NSString, and names it StringHelper. I also added the definition for my method to determine the height of the text. Bascially, I’ll be using a cell that is always the same width, so the height is the value I’m interested in. In other situations you may need more flexibility. To keep it simple, I decided to allow for only one variable, the font size. I prefixed it with RAD only as a convention, and to avoid name conflicts. Here’s the implementation:

#import "StringHelper.h" 

@implementation NSString (StringHelper)

- (CGFloat)RAD_textHeightForSystemFontOfSize:(CGFloat)size {
    //Calculate the expected size based on the font and linebreak mode of the label
    CGFloat maxWidth = [UIScreen mainScreen].bounds.size.width - 50;
    CGFloat maxHeight = 9999;
    CGSize maximumLabelSize = CGSizeMake(maxWidth,maxHeight);

    CGSize expectedLabelSize = [self sizeWithFont:[UIFont systemFontOfSize:size] constrainedToSize:maximumLabelSize lineBreakMode:UILineBreakModeWordWrap]; 

    return expectedLabelSize.height;
}

@end

First define the maximum width, I’m setting it to be 50 pixels narrower than the width of the screen. Then the maximum height. I then use the CGSizeMake method with the maxWidth and maxHeight variables to create a maximum label size variable that will be passed to the sizeWithFont: contstrainedToSize: lineBreakMode method. I am only interested in the height for my table view cell, so I return only the height.

Now I know the height of the cell. I also need to create a label to go into the cell. The label will need to be initiated with the proper width, height and properties for the font. Once again, I’d like to make it convenient to re-use in my application, so let’s create another helper method. Since it still depends on the string, add this method to the StringHelper Category too.

Add this to the StringHelper.h file:

- (UILabel *)RAD_sizeCellLabelWithSystemFontOfSize:(CGFloat)size;

This method will return a UILabel sized to hold our text. Here’s the implementation:

- (UILabel *)RAD_sizeCellLabelWithSystemFontOfSize:(CGFloat)size {
    CGFloat width = [UIScreen mainScreen].bounds.size.width - 50;
    CGFloat height = [self RAD_textHeightForSystemFontOfSize:size] + 10.0;
    CGRect frame = CGRectMake(10.0f, 10.0f, width, height);
    UILabel *cellLabel = [[UILabel alloc] initWithFrame:frame];
    cellLabel.textColor = [UIColor blackColor];
    cellLabel.backgroundColor = [UIColor clearColor];
    cellLabel.textAlignment = UITextAlignmentLeft;
    cellLabel.font = [UIFont systemFontOfSize:size];

    cellLabel.text = self; 
    cellLabel.numberOfLines = 0; 
    [cellLabel sizeToFit];
    return cellLabel;
}

The first two lines determine the width and height for the label’s frame. To get the height, I re-used the helper method defined previously. Use these values to create a CGRect that defines the rectangle the label will occupy. After that, initialize the label and set a few properties for color, alignment, and font. Set the text of the label to the string. Setting the numberOfLines property of the cellLabel to 0 removes any maximum limit to the number of lines.

With these two methods we have everything we need to define our table view cell with a label that will hold long strings.

Configure the Table View

Configuring the table view is fairly easy. Import the StringHelper.h file. I also defined two constants to give myself an easy way to change the values. Add this to the top of the RootViewController.m file.

#import "StringHelper.h" 

//Text View contstants
#define kTextViewFontSize        18.0
#define kDefaultNoteLabel        @"Add a Note"

Define the height for the row that will hold the cell:

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath  {  
    NSString *label = [self.aNote length] == 0 ? kDefaultNoteLabel : self.aNote;
    CGFloat height = [label RAD_textHeightForSystemFontOfSize:kTextViewFontSize] + 20.0;
    return height;
}

Nothing special going on here. If the aNote variable is empty, use the “Add a Note” string as the label. Then use the StringHelper method to calculate the height. I added a little extra space to the height of the cell so that the label would fit comfortably within the cell.

Finally, define the cell with the label.

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {

    static NSString *CellIdentifier = @"NoteCell";

    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:CellIdentifier];
    if (cell == nil) {
        cell = [[[UITableViewCell alloc] initWithFrame:CGRectZero reuseIdentifier:CellIdentifier] autorelease];
    }

    //Working with a resizable note cell
    //If there is already a subview, remove it.
    if ([[cell.contentView subviews] count] > 0) {
        UIView *labelToClear = [[cell.contentView subviews] objectAtIndex:0];
        [labelToClear removeFromSuperview];
    }

    UILabel *cellLabel;
    NSString *label = [self.aNote length] == 0 ? kDefaultNoteLabel : self.aNote;

    cellLabel = [label RAD_sizeCellLabelWithSystemFontOfSize:kTextViewFontSize];

    [cell.contentView addSubview:cellLabel];
    [cellLabel release];
    cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator;
    cell.hidesAccessoryWhenEditing = YES;
    return cell;
}

This code defines a label using the helper method and then adds the label as a subview to the cell. If the aNote variable is empty, it uses the “Add a Note” string as the label, otherwise it uses the value of the variable. Before adding the subview, it checks to see if any subviews already exist. If one does, it is removed. This prevents text from being layered on top of other subviews. Which isn’t pretty. Unless your going for a grungy look.

There’s still one task left. If we build and run the project now, everything should compile, and the “Add a Note” text should appear with a properly sized cell. The problem is that after we enter text and return to this view, our new text will not be displayed. To take care of this, we need to add an IBOutlet for the table view and wire it up in Interface Builder so that we can reload the data.

Add an IBOutlet in Interface Builder

First add an outlet to the RootViewController.h file:

@interface RootViewController : UITableViewController {
    AddNoteViewController *addNoteController;
    IBOutlet UITableView *tbView;
    NSString *aNote;
}

Note: The RootViewController.xib file already contains an outlet called tableView that may be used. If you add an outlet with the same name you won’t have to change anything in Interface Builder. I like to give the table view a different name to help me keep it clear when debugging, but there is no real need to do this. If you change the name as I did, just open the file in IB, delete the old outlet and connect the new one.

When the view appears, tell the table view to reload data.

- (void)viewDidAppear:(BOOL)animated {
    [tbView reloadData];
}

That’s it. At this point you should be able to build the project, add a note, and see the whole note in the cell when done. Removing or adding text to the note should also resize the cell after saving.

Re-factoring to Improve Performance

Thanks to Deegee’s comment below, I decided to re-factor the code to resize the label if it already exists.

Here are the changes:

First I created two new helper methods in the category like this:

- (CGRect)RAD_frameForCellLabelWithSystemFontOfSize:(CGFloat)size {
    CGFloat width = [UIScreen mainScreen].bounds.size.width - 50;
    CGFloat height = [self RAD_textHeightForSystemFontOfSize:size] + 10.0;
    return CGRectMake(10.0f, 10.0f, width, height);
}

- (void)RAD_resizeLabel:(UILabel *)aLabel WithSystemFontOfSize:(CGFloat)size {
    aLabel.frame = [self RAD_frameForCellLabelWithSystemFontOfSize:size];
    aLabel.text = self;
    [aLabel sizeToFit];
}

The RAD_frameForCellLabelWithSystemFontOfSize: method is simply to help keep the code dry. I pulled it into a new method rather than repeating those lines of code.

I then changed the code in the RootViewController.m file as follows:

// Customize the appearance of table view cells.
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {

    static NSString *CellIdentifier = @"NoteCell";

    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:CellIdentifier];
    if (cell == nil) {
        cell = [[[UITableViewCell alloc] initWithFrame:CGRectZero reuseIdentifier:CellIdentifier] autorelease];
    }

    NSString *label = [self.aNote length] == 0 ? kDefaultNoteLabel : self.aNote;

    //Working with a resizable note cell
    //If there is already a subview, resize it, else create it.
    if ([[cell.contentView subviews] count] > 0) {
        id view = [[cell.contentView subviews] objectAtIndex:0];
        UILabel *labelToSize = view;
        [label RAD_resizeLabel:labelToSize WithSystemFontOfSize:kTextViewFontSize];
    } else {
        UILabel *cellLabel;
        cellLabel = [label RAD_newSizedCellLabelWithSystemFontOfSize:kTextViewFontSize];
        [cell.contentView addSubview:cellLabel];
        [cellLabel release];
    }

    cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator;
    cell.hidesAccessoryWhenEditing = YES;
    return cell;
}
Finally, I renamed the method that creates a new label to indicate that it would be returning a new label as follows:
- (UILabel *)RAD_newSizedCellLabelWithSystemFontOfSize:(CGFloat)size;

Here’s the sample project: UITextView2.zip

Comments

velkropie said on Monday, April 27, 2009:

hello,
thanks for taking the time to right this tutorial.

is there a way for you to show how you would save the text to a database. i've created a database but dont know this very well...thanks

deegee said on Thursday, May 14, 2009:

Two comments on your approach:
- You're reallocating a new UILabel each time you draw a cell (removing the label and allocating a new one with the right size). Why not reuse the already existing label and just adjust its frame? The whole process of allocating new objects is much more costly on resources than just changing properties.

- Your method RAD_sizeCellLabelWithSystemFontOfSize: breaks the Obj-C convention: It should contain 'new' or 'alloc' to indicate that it returns an object with a retain count of 1 and the caller needs to release it. Either autorelease your UILabel or name it newSizedCell... Also it starts with capitals (use rad_ instead).

Tim Stephenson said on Thursday, May 14, 2009:

Hi deegee

Thanks. I re-factored the sample, great suggestion. In terms of the caps RAD, I simply followed the Aaron Hillegass's example in Cocoa Programming for Mac OS X. I guess I'm a lemming. I kept it in the sample because it is a convention that I'm following elsewhere in my projects.

indiekiduk said on Sunday, May 24, 2009:

You can use UILabel sizeToFit to do this much faster. Unfortunately sizeToFit doesn't work for UITextView at the moment.

Joe said on Thursday, June 25, 2009:

My understanding is that the (UIView) frame property inherited by UILabel needs to be updated before calling sizeToFit ... which is where this tutorial comes in to play. (Is this accurate?)

Gareth Curtis said on Tuesday, June 30, 2009:

Great tutorial (thanks) but currently I'm failing how I can make this work for many cells within the same table. How would one achieve this? I don't want to have all my variables listed within the tableView's heightForRowAtIndexPath section.....

Any ideas?

Gareth.

Gareth Curtis said on Tuesday, June 30, 2009:

Worth noting, from the docs:

For heightForRowAtIndexPath:

Important: Due to an underlying implementation detail, you should not return values greater than 2009.

haiku said on Friday, August 07, 2009:

problem with the method this and every other sample uses is that they aren't really creating reusable cells. They are reloading the cell from the nib and creating a new one every time.

There is some subtle thing that prevents them from being re-used, somehow, even though they have a reuseIdentifier set in the nib/xib file.

They are not being added to the _reusableTableCells dictionary and thus are never found by the call to dequeue a reusable cell.

you only have single rows in your tables so you are not seeing this issue, but with a table of many items you will.

I have yet to find a solution to this on the web or in my own coding. It's a problem because everyone really wants to create their cells in IB and then have them reusable (lots of posts).

c_phlat said on Monday, September 13, 2010:

Yeah, haiku raised a good point that I am also struggling with. If you have multiple cells and/or sections of cells (particularly if they scroll off the screen), there's a bug in which previous text regions are overwritten onto one another.

I'm sure this has to do with haiku's point about cell's being re-used/dequeued, but I'm too new to iPhone development to see a solution to it ...

bdyer said on Monday, September 27, 2010:

text regions being overwritten is caused by using the same cell identifier when you have multiple sections in a UITableView. to avoid this you must create a unique cell identifier for each section. the index path will tell you what section you are in.
ie: indexPath.section

Yuri said on Friday, October 08, 2010:

Thanks for interesting articles.
That’s actually only source on this topic on the web.
But I like the way how self resizable cell is working for Notes in IPhone contact application.
How they manage to do this. Probably cell is contently updated behind the scene.
Can you at least explain approach to it design?

college grants said on Friday, December 03, 2010:

Couldnt agree more with that, very attractive article

Craig said on Sunday, December 12, 2010:

Thanks man, that really helped me out.

soundtracks said on Saturday, January 29, 2011:

+1 ))