I have seen this question asked many times but astoundingly, I have not seen a consistent answer, so I will give it a try myself:
If you have a tableview containing
The way Apple implements UITableView is not intuitive to everyone and it's easy to misunderstand the role of heightForRowAtIndexPath:
. The general intention is that this is a faster and light-on-memory method that can be called for every row in the table quite frequently. This contrasts with cellForRowAtIndexPath:
which is often slower and more memory intensive, but is only called for the rows that are actually need to be displayed at any given time.
Why do Apple implement it like this? Part of the reason is that it's almost always cheaper (or can be cheaper if you code it right) to calculate the height of a row than it is to build and populate a whole cell. Given that in many tables the height of every cell will be identical, it is often vastly cheaper. And another part of the reason is because iOS needs to know the size of the whole table: this allows it to create the scroll bars and set it up on a scroll view etc.
So, unless every cell height is the same, then when a UITableView is created and whenever you send it a reloadData message, the datasource is sent one heightForRowAtIndexPath message for each cell. So if your table has 30 cells, that message gets sent 30 times. Say only six of those 30 cells are visible on screen. In that case, when created and when you send it a reloadData message, the UITableView will send one cellForRowAtIndexPath message per visible row, i.e. that message gets sent six times.
Some people are sometimes puzzled about how to calculate a cell height without creating the views themselves. But usually this is easy to do.
For example, if your row heights vary in size because they hold varying amounts of text, you can use one of the sizeWithFont:
methods on the relevant string to do the calculations. This is quicker than building a view and then measuring the result. Note, that if you change the height of a cell, you will need to either reload the whole table (with reloadData - this will ask the delegate for every height, but only ask for visible cells) OR selectively reload the rows where the size has changed (which, last time I checked, also calls heightForRowAtIndexPath:
on ever row but also does some scrolling work for good measure).
See this question and perhaps also this one.
An approach I have used in the past is to create a class variable to hold a single instance of the cell you are going to be using in the table (I call it a prototype cell). Then in the custom cell class I have a method to populate the data and determine the height the cell needs to be. Note that it can be a simpler variant of the method to really populate the data - instead of actually resizing a UILabel in a cell for example, it can just use the NSString height methods to determine how tall the UILabel would be in the final cell and then use the total cell height (plus a border on the bottom) and UILabel placement to determine the real height. YOu use the prototype cell just to get an idea of where elements are placed so you know what it means when a label is going to be 44 units high.
In heightForRow:
I then call that method to return the height.
In cellForRow:
I use the method that actually populates labels and resizes them (you never resize the UITableView cell yourself).
If you want to get fancy, you can also cache the height for each cell based on the data you pass in (for instance it could just be on one NSString if that's all that determines height). If you have a lot of data that's often the same it may make sense to have a permanent cache instead of just in-memory.
You can also try estimating line count based on character or word count, but in my experience that never works - and when it goes wrong it usually messes up a cell and all the cells below it.
Here is what I do in very simple case, a cell containing a note held in a label. The note itself is constrained to a maximum length I am imposing, so I use a multi-line UILabel and I compute dynamically the correct eight for each cell as shown in the following example. You can deal with an UITextView pretty much the same.
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
static NSString *CellIdentifier = @"Cell";
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:CellIdentifier];
if (cell == nil) {
cell = [[[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:CellIdentifier] autorelease];
}
// Configure the cell...
Note *note = (Note *) [fetchedResultsController objectAtIndexPath:indexPath];
cell.textLabel.text = note.text;
cell.textLabel.numberOfLines = 0; // no limits
DateTimeHelper *dateTimeHelper = [DateTimeHelper sharedDateTimeHelper];
cell.detailTextLabel.text = [dateTimeHelper mediumStringForDate:note.date];
cell.accessoryType = UITableViewCellAccessoryDetailDisclosureButton;
return cell;
}
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath{
//NSLog(@"heightForRowAtIndexPath: Section %d Row %d", indexPath.section, indexPath.row);
UITableViewCell *cell = [self tableView: self.tableView cellForRowAtIndexPath: indexPath];
NSString *note = cell.textLabel.text;
UIFont *font = [UIFont fontWithName:@"Helvetica" size:14.0];
CGSize constraintSize = CGSizeMake(280.0f, MAXFLOAT);
CGSize bounds = [note sizeWithFont:font constrainedToSize:constraintSize lineBreakMode:UILineBreakModeWordWrap];
return (CGFloat) cell.bounds.size.height + bounds.height;
}
I went with the idea I originally proposed, which appears to work fine, whereby I load all the custom cells ahead of time in viewDidLoad, store them in a NSMutableDictionary with their index as the key. I am posting the relevant code and would love any critiques or opinions anyone has about this approach. Specifically, I am not sure whether there is any memory leak issue with the way I am creating the UITableViewCells from the nib in viewDidLoad - since I don't release them.
@interface RecentController : UIViewController <UITableViewDelegate, UITableViewDataSource> {
NSArray *listData;
NSMutableDictionary *cellBank;
}
@property (nonatomic, retain) NSArray *listData;
@property (nonatomic, retain) NSMutableDictionary *cellBank;
@end
@implementation RecentController
@synthesize listData;
@synthesize cellBank;
---
- (void)viewDidLoad {
---
self.cellBank = [[NSMutableDictionary alloc] init];
---
//create question objects…
---
NSArray *array = [[NSArray alloc] initWithObjects:question1,question2,question3, nil];
self.listData = array;
//Pre load all table row cells
int count = 0;
for (id question in self.listData) {
NSArray *nib = [[NSBundle mainBundle] loadNibNamed:@"QuestionHeaderCell"
owner:self
options:nil];
QuestionHeaderCell *cell;
for (id oneObject in nib) {
if([oneObject isKindOfClass:[QuestionHeaderCell class]])
cell = (QuestionHeaderCell *) oneObject;
NSNumber *key = [NSNumber numberWithInt:count];
[cellBank setObject:[QuestionHeaderCell makeCell:cell
fromObject:question]
forKey:key];
count++;
}
}
[array release];
[super viewDidLoad];
}
#pragma mark -
#pragma mark Table View Data Source Methods
-(NSInteger) tableView: (UITableView *) tableView
numberOfRowsInSection: (NSInteger) section{
return [self.listData count];
}
-(UITableViewCell *) tableView: (UITableView *) tableView
cellForRowAtIndexPath: (NSIndexPath *) indexPath{
NSNumber *key = [NSNumber numberWithInt:indexPath.row];
return [cellBank objectForKey:key];
}
-(CGFloat) tableView: (UITableView *) tableView
heightForRowAtIndexPath: (NSIndexPath *) indexPath{
NSNumber *key = [NSNumber numberWithInt:indexPath.row];
return [[cellBank objectForKey:key] totalCellHeight];
}
@end
@interface QuestionHeaderCell : UITableViewCell {
UITextView *title;
UILabel *createdBy;
UILabel *category;
UILabel *questionText;
UILabel *givenBy;
UILabel *date;
int totalCellHeight;
}
@property (nonatomic, retain) IBOutlet UITextView *title;
@property (nonatomic, retain) IBOutlet UILabel *category;
@property (nonatomic, retain) IBOutlet UILabel *questionText;
@property (nonatomic, retain) IBOutlet UILabel *createdBy;
@property (nonatomic, retain) IBOutlet UILabel *givenBy;
@property (nonatomic, retain) IBOutlet UILabel *date;
@property int totalCellHeight;
+(UITableViewCell *) makeCell:(QuestionHeaderCell *) cell
fromObject:(Question *) question;
@end
@implementation QuestionHeaderCell
@synthesize title;
@synthesize createdBy;
@synthesize givenBy;
@synthesize questionText;
@synthesize date;
@synthesize category;
@synthesize totalCellHeight;
- (void)dealloc {
[title release];
[createdBy release];
[givenBy release];
[category release];
[date release];
[questionText release];
[super dealloc];
}
+(UITableViewCell *) makeCell:(QuestionHeaderCell *) cell
fromObject:(Question *) question{
NSUInteger currentYpos = 0;
cell.title.text = question.title;
CGRect frame = cell.title.frame;
frame.size.height = cell.title.contentSize.height;
cell.title.frame = frame;
currentYpos += cell.title.frame.size.height + 2;
NSMutableString *tempString = [[NSMutableString alloc] initWithString:question.categoryName];
[tempString appendString:@"/"];
[tempString appendString:question.subCategoryName];
cell.category.text = tempString;
frame = cell.category.frame;
frame.origin.y = currentYpos;
cell.category.frame = frame;
currentYpos += cell.category.frame.size.height;
[tempString setString:@"Asked by "];
[tempString appendString:question.username];
cell.createdBy.text = tempString;
frame = cell.createdBy.frame;
frame.origin.y = currentYpos;
cell.createdBy.frame = frame;
currentYpos += cell.createdBy.frame.size.height;
cell.questionText.text = question.text;
frame = cell.questionText.frame;
frame.origin.y = currentYpos;
cell.questionText.frame = frame;
currentYpos += cell.questionText.frame.size.height;
[tempString setString:@"Advice by "];
[tempString appendString:question.lastNexusUsername];
cell.givenBy.text = tempString;
frame = cell.givenBy.frame;
frame.origin.y = currentYpos;
cell.givenBy.frame = frame;
currentYpos += cell.givenBy.frame.size.height;
cell.date.text = [[[MortalDataStore sharedInstance] dateFormat] stringFromDate: question.lastOnDeck];
frame = cell.date.frame;
frame.origin.y = currentYpos-6;
cell.date.frame = frame;
currentYpos += cell.date.frame.size.height;
//Set the total height of cell to be used in heightForRowAtIndexPath
cell.totalCellHeight = currentYpos;
[tempString release];
return cell;
}
@end
This is how I calculate the height of a cell based on the amount of text in a UTextView:
#define PADDING 21.0f
- (CGFloat)tableView:(UITableView *)t heightForRowAtIndexPath:(NSIndexPath *)indexPath {
if(indexPath.section == 0 && indexPath.row == 0)
{
NSString *practiceText = [practiceItem objectForKey:@"Practice"];
CGSize practiceSize = [practiceText sizeWithFont:[UIFont systemFontOfSize:14.0f]
constrainedToSize:CGSizeMake(tblPractice.frame.size.width - PADDING * 3, 1000.0f)];
return practiceSize.height + PADDING * 3;
}
return 72;
}
Of course, you would need to adjust the PADDING
and other variables to fit your needs, but this sets the height of the cell which has a UITextView
in it, based on the amount of text supplied. so if there are only 3 lines of text, the cell is fairly short, where as if there are 14 lines of text, the cell is rather large in height.
The problem with moving the calculation of each cell to tableView:heightForRowAtIndexPath: is that all the cells are then recalculated every time reloadData is called. Way too slow, at least for my application where there may be 100's of rows. Here's an alternative solution that uses a default row height, and caches the row heights when they are calculated. When a height changes, or is first calculated, a table reload is scheduled to inform the table view of the new heights. This does mean that rows are displayed twice when their heights change, but that's minor in comparison:
@interface MyTableViewController : UITableViewController {
NSMutableDictionary *heightForRowCache;
BOOL reloadRequested;
NSInteger maxElementBottom;
NSInteger minElementTop;
}
tableView:heightForRowAtIndexPath:
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath
{
// If we've calculated the height for this cell before, get it from the height cache. If
// not, return a default height. The actual size will be calculated by cellForRowAtIndexPath
// when it is called. Do not set too low a default or UITableViewController will request
// too many cells (with cellForRowAtIndexPath). Too high a value will cause reloadData to
// be called more times than needed (as more rows become visible). The best value is an
// average of real cell sizes.
NSNumber *height = [heightForRowCache objectForKey:[NSNumber numberWithInt:indexPath.row]];
if (height != nil) {
return height.floatValue;
}
return 200.0;
}
tableView:cellForRowAtIndexPath:
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
// Get a reusable cell
UITableViewCell *currentCell = [tableView dequeueReusableCellWithIdentifier:_filter.templateName];
if (currentCell == nil) {
currentCell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:_filter.templateName];
}
// Configure the cell
// +++ unlisted method sets maxElementBottom & minElementTop +++
[self configureCellElementLayout:currentCell withIndexPath:indexPath];
// Calculate the new cell height
NSNumber *newHeight = [NSNumber numberWithInt:maxElementBottom - minElementTop];
// When the height of a cell changes (or is calculated for the first time) add a
// reloadData request to the event queue. This will cause heightForRowAtIndexPath
// to be called again and inform the table of the new heights (after this refresh
// cycle is complete since it's already been called for the current one). (Calling
// reloadData directly can work, but causes a reload for each new height)
NSNumber *key = [NSNumber numberWithInt:indexPath.row];
NSNumber *oldHeight = [heightForRowCache objectForKey:key];
if (oldHeight == nil || newHeight.intValue != oldHeight.intValue) {
if (!reloadRequested) {
[self.tableView performSelector:@selector(reloadData) withObject:nil afterDelay:0];
reloadRequested = TRUE;
}
}
// Save the new height in the cache
[heightForRowCache setObject:newHeight forKey:key];
NSLog(@"cellForRow: %@ height=%@ >> %@", indexPath, oldHeight, newHeight);
return currentCell;
}