51

I have a UITableView with a few different sections. One section contains cells that will resize as a user types text into a UITextView. Another section contains cells that render HTML content, for which calculating the height is relatively expensive.

Right now when the user types into the UITextView, in order to get the table view to update the height of the cell, I call

[self.tableView beginUpdates];
[self.tableView endUpdates];

However, this causes the table to recalculate the height of every cell in the table, when I really only need to update the single cell that was typed into. Not only that, but instead of recalculating the estimated height using tableView:estimatedHeightForRowAtIndexPath:, it calls tableView:heightForRowAtIndexPath: for every cell, even those not being displayed.

Is there any way to ask the table view to update just the height of a single cell, without doing all of this unnecessary work?

Update

I'm still looking for a solution to this. As suggested, I've tried using reloadRowsAtIndexPaths:, but it doesn't look like this will work. Calling reloadRowsAtIndexPaths: with even a single row will still cause heightForRowAtIndexPath: to be called for every row, even though cellForRowAtIndexPath: will only be called for the row you requested. In fact, it looks like any time a row is inserted, deleted, or reloaded, heightForRowAtIndexPath: is called for every row in the table cell.

I've also tried putting code in willDisplayCell:forRowAtIndexPath: to calculate the height just before a cell is going to appear. In order for this to work, I would need to force the table view to re-request the height for the row after I do the calculation. Unfortunately, calling [self.tableView beginUpdates]; [self.tableView endUpdates]; from willDisplayCell:forRowAtIndexPath: causes an index out of bounds exception deep in UITableView's internal code. I guess they don't expect us to do this.

I can't help but feel like it's a bug in the SDK that in response to [self.tableView endUpdates] it doesn't call estimatedHeightForRowAtIndexPath: for cells that aren't visible, but I'm still trying to find some kind of workaround. Any help is appreciated.

Chris Vasselli
  • 13,064
  • 4
  • 46
  • 49
  • 1
    Why don't you use `reloadRowsAtIndexPaths:`? – Wain Oct 15 '13 at 07:03
  • I just tried this, and it looks like `reloadRowsAtIndexPaths:` also triggers the height of all rows to be recalculated. I don't think it's really what I want anyway, since it will create a new cell instead of resizing the existing one. – Chris Vasselli Oct 15 '13 at 07:19
  • The reason that heightForCell is called for every cell is that the table view needs to know what it's total height is, which it can only get from calling heightForCell for every cell that is likely to be shown on the table. – Abizern Oct 23 '13 at 09:04
  • Probably on you to cache the row heights? – nielsbot Oct 25 '13 at 22:35
  • sorry--I see you are doing that. – nielsbot Oct 25 '13 at 22:51
  • you could cache the heights if youre worried about calling it every time – scord Dec 22 '17 at 21:42

8 Answers8

33

As noted, reloadRowsAtIndexPaths:withRowAnimation: will only cause the table view to ask its UITableViewDataSource for a new cell view but won't ask the UITableViewDelegate for an updated cell height.

Unfortunately the height will only be refreshed by calling:

[tableView beginUpdates];
[tableView endUpdates];

Even without any change between the two calls.

If your algorithm to calculate heights is too time consuming maybe you should cache those values. Something like:

- (CGFloat)tableView:(UITableView *)tableView
heightForRowAtIndexPath:(NSIndexPath *)indexPath
{
    CGFloat height = [self cachedHeightForIndexPath:indexPath];

    // Not cached ?
    if (height < 0)
    {
        height = [self heightForIndexPath:indexPath];
        [self setCachedHeight:height
                 forIndexPath:indexPath];
    }

    return height;
}

And making sure to reset those heights to -1 when the contents change or at init time.

Edit:

Also if you want to delay height calculation as much as possible (until they are scrolled to) you should try implementing this (iOS 7+ only):

@property (nonatomic) CGFloat estimatedRowHeight

Providing a nonnegative estimate of the height of rows can improve the performance of loading the table view. If the table contains variable height rows, it might be expensive to calculate all their heights when the table loads. Using estimation allows you to defer some of the cost of geometry calculation from load time to scrolling time.

The default value is 0, which means there is no estimate.

Rivera
  • 10,792
  • 3
  • 58
  • 102
  • Thanks for the response. I'm actually already caching my calculated heights, as you suggested, and that does solve part of the problem. But when a user edits one of the resizable cells for the first time, it still calculates all of the heights at once, which causes a significant lag. – Chris Vasselli Oct 23 '13 at 08:48
  • But if I understand well, by the time the user starts editing a cell most of the neighbor cells' heights all already cached, so there should be little impact. – Rivera Oct 23 '13 at 09:02
  • In my case, the cells that are editable come before the cells that are expensive to calculate. So, when a user starts editing the cell, usually none of the expensive cells' heights have been calculated yet. – Chris Vasselli Oct 23 '13 at 09:07
  • You could kick off a background thread to calculate the heights of the next few rows expected to be displayed... or take some extra time to calculate before you display your table view on screen... – nielsbot Oct 25 '13 at 22:36
  • I think that's a promising idea nielsbot. I think the difficult part of using a background thread would be figuring out the right time to tell the table view to recalculate the heights, since clearly there are times when calling beginUpdates, endUpdates causes problems. – Chris Vasselli Oct 27 '13 at 06:01
  • I think that forward calculating heights (even on background threads) defeats the design of lazy loading/calculation. I think Apple wants you to use heigh estimations as in my edit, that way you don't even need to manually ask the table to refresh heights. – Rivera Oct 28 '13 at 02:41
  • After seeing the above answer and learning about estimateRowHeight, I tried it in my own large table. It was actually slower than my previous scheme that used a custom estimated height in heightForRowAtIndexPath. – Casey Perkins Jul 03 '14 at 16:02
  • The similar solution is to call `moveRowAtIndexPath:toIndexPath:` for the same `indexPath` [(see details here)](http://stackoverflow.com/a/37623597/2066428) – malex Jun 03 '16 at 21:22
27

This bug has been fixed in iOS 7.1.

In iOS 7.0, there doesn't seem to be any way around this problem. Calling [self.tableView endUpdates] causes heightForRowAtIndexPath: to be called for every cell in the table.

However, in iOS 7.1, calling [self.tableView endUpdates] causes heightForRowAtIndexPath: to be called for visible cells, and estimatedHeightForRowAtIndexPath: to be called for non-visible cells.

Chris Vasselli
  • 13,064
  • 4
  • 46
  • 49
  • I have same problem for the case : When I load multiline data with tableView, height of the each cell calculate perfectly, but the tap on edit button and open tableview in edit mode, at that time height of the visible cells are calculated perfectly, then after I scroll down to view another cells at that time height is not calculated right. Can you help me on this ? – Divya Bhaloidiya Dec 02 '14 at 14:11
8

Variable row heights have a very negative impact on your table view performance. You are talking about web content that is displayed in some of the cells. If we are not talking about thousands of rows, thinking about implementing your solution with a UIWebView instead of a UITableView might be worth considering. We had a similar situation and went with a UIWebView with custom generated HTML markup and it worked beautifully. As you probably know, you have a nasty asynchronous problem when you have a dynamic cell with web content:

  • After setting the content of the cell you have to
  • wait until the web view in the cell is done rendering the web content,
  • then you have to go into the UIWebView and - using JavaScript - ask the HTML document how high it is
  • and THEN update the height of the UITableViewCell.

No fun at all and lots of jumping and jittering for the user.

If you do have to go with a UITableView, definitely cache the calculated row heights. That way it will be cheap to return them in heightForRowAtIndexPath:. Instead of telling the UITableView what to do, just make your data source fast.

Johannes Fahrenkrug
  • 42,912
  • 19
  • 126
  • 165
  • This is a very interesting idea. It would be a shame to lose the native look-and-feel of a UITableView, and I worry that it would take a long time to load all of the content, but I can see how this could be a viable alternative solution. – Chris Vasselli Oct 25 '13 at 03:52
  • I haven't had a chance to try to implement this yet, but it seems like more than any other answer it has the potential to get around all the problems I've run into, so I think you deserve the bounty. Congrats! – Chris Vasselli Oct 27 '13 at 06:07
  • Thank you very kindly! Maybe you can post an update about what solution you ended up using. – Johannes Fahrenkrug Oct 28 '13 at 12:16
2

Is there a way? The answer is no.

You can only use heightForRowAtIndexPath for this. So all you can do is make this as inexpensive as possible by for example keeping an NSmutableArray of your cell heights in your data model.

ader
  • 5,403
  • 1
  • 21
  • 26
  • This is exactly correct, and the true answer! (WIth iOS7 the problem has been solved with the new and subtle "estimated" system.) In iOS6, the best you can do is something crazy like, actually on your database on the server, ALSO keep the heights of images, text rendering, etc etc. Not much else you can do. – Fattie Nov 25 '13 at 11:06
2

I had a similar issue(jumping scroll of the tableview on any change) because I had

  • (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath { return 500; }

commenting the entire function helped.

soprof
  • 326
  • 2
  • 6
1

Use the following UITableView method:

- (void)reloadRowsAtIndexPaths:(NSArray *)indexPaths withRowAnimation:(UITableViewRowAnimation)animation

You have to specify an NSArray of NSIndexPath which you want to reload. If you want to reload only one cell, then you can supply an NSArray that holds only one NSIndexPath.

NSIndexPath* rowTobeReloaded = [NSIndexPath indexPathForRow:1 inSection:0];
NSArray* rowsTobeReloaded = [NSArray arrayWithObjects:rowTobeReloaded, nil];
[UITableView reloadRowsAtIndexPaths:rowsTobeReloaded withRowAnimation:UITableViewRowAnimationNone];
iDeveloper
  • 940
  • 1
  • 10
  • 45
Nilesh Patel
  • 6,318
  • 1
  • 26
  • 40
  • 5
    Unfortunately, it looks like reloading the row is different than recalculating the size. Calling `reloadRowsAtIndexPaths:` with even a single row will still cause `heightForRowAtIndexPath:` to be called for **every** row, even though `cellForRowAtIndexPath:` will only be called for the row you requested. – Chris Vasselli Oct 15 '13 at 07:39
0

The method heightForRowAtIndexPath: will always be called but here's a workaround that I would suggest.

Whenever the user is typing in the UITextView, save in a local variable the indexPath of the cell. Then, when heightForRowAtIndexPath: is called, verify the value of the saved indexPath. If the saved indexPath isn't nil, retrieve the cell that should be resized and do so. As for the other cells, use your cached values. If the saved indexPath is nil, execute your regular lines of code which in your case are demanding.

Here's how I would recommend doing it:

Use the property tag of UITextView to keep track of which row needs to be resized.

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

    [textView setDelegate:self];
    [textView setTag:indexPath.row];

    ...
}

Then, in your UITextView delegate's method textViewDidChange:, retrieve the indexPath and store it. savedIndexPath is a local variable.

- (void)textViewDidChange:(UITextView *)textView
{
    savedIndexPath = [NSIndexPath indexPathForRow:textView.tag inSection:0];
}

Finally, check the value of savedIndexPath and execute what it's needed.

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath
{
    if (savedIndexPath != nil) {
        if (savedIndexPath == indexPath.row) {
            savedIndexPath = nil;

            // return the new height
        }
        else {

            // return cached value
        }
    }
    else {
        // your normal calculating methods...
    }
}

I hope this helps! Good luck.

jbrodriguez
  • 119
  • 4
  • Thanks for your response. The problem is, when you start typing in the UITextView, none of the expensive cells have been displayed yet, and nothing in the cache is populated yet. So, it will fall back to the expensive calculation for all the cells. – Chris Vasselli Oct 27 '13 at 06:04
0

I ended up figuring out a way to work around the problem. I was able to pre-calculate the height of the HTML content I need to render, and include the height along with the content in the database. That way, although I'm still forced to provide the height for all cells when I update the height of any cell, I don't have to do any expensive HTML rendering so it's pretty snappy.

Unfortunately, this solution only works if you've got all your HTML content up-front.

Chris Vasselli
  • 13,064
  • 4
  • 46
  • 49