UITableView scroll to the bottom messed up with EstimatedRowHeight - ios

I'm trying to scroll to the bottom of my UITableView (commentsFeed) whenever the user creates a new comment or the user refreshes the UITableView.
The code I use is:
func scrollToBottomOfComments() {
var lastRowNumber = commentsFeed.numberOfRowsInSection(0) - 1
var indexPath = NSIndexPath(forRow: lastRowNumber, inSection: 0)
commentsFeed.scrollToRowAtIndexPath(indexPath, atScrollPosition: .Bottom, animated: true)
}
The problem is here in viewDidLoad:
commentsFeed.rowHeight = UITableViewAutomaticDimension
commentsFeed.estimatedRowHeight = 150
This basically states that the comments can have dynamic heights because users could post either really long comments or really short comments.
When I use the estimatedRowHeight, my scrollToBottom doesn't properly scroll to the bottom because it basically assumes my table height is commentsFeed.count * commentsFeed.estimatedRowHeight
This isn't correct though.
When I remove the estimatedRowHeight though, it doesn't seem to work either, and I think the reason is because it doesn't have the row height calculated properly because the rows each have dynamic heights.
How do I mitigate this?
Edit: It should be stated that the scroll doesn't end up at the right position, but the moment I use my finger to scroll anywhere, then the data jumps into place where it should have been via the scroll

Why don't you calculate the real size of row by something similar to below method.
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
CustomObject *message = [list.fetchedObjects objectAtIndex:indexPath.row];
//fontChat not available yet
NSMutableAttributedString *text = [[NSMutableAttributedString alloc] initWithString:message];
NSRange all = NSMakeRange(0, text.length);
[text addAttribute:NSFontAttributeName value:[UIFont fontWithName:DEFAULT_FONT size:21] range:all];
[text addAttribute:NSForegroundColorAttributeName value:RGB(61, 61, 61) range:all];
CGSize theSize = [text boundingRectWithSize:CGSizeMake(200, MAXFLOAT) options:NSStringDrawingUsesLineFragmentOrigin context:nil].size;
if (theSize.height == 0) {
theSize.height = FIXED_SIZE;
}
return theSize.height;
}
Now for scrolling, I use the following source:
Check it out.
-(void) scrollTolastRow
{
if (self.tableView.contentSize.height > self.tableView.frame.size.height)
{
CGPoint offset = CGPointMake(0, self.tableView.contentSize.height - self.tableView.frame.size.height);
[self.tableView setContentOffset:offset animated:YES];
}
}

There are a couple things that could be affecting your outcome, but it's most likely that your estimated height is not a great estimate. In my experience, anything not particularly close to the true height of the cells will cause havoc on animations. In your case, you mention that the content are comments, or free-form text. I would guess that these cell heights vary wildly, and depending on how your cell is composed, you're probably not going to be able to provide a very accurate estimate, and so you should not provide one. For a large number of cells, this is going to hurt performance, but you probably don't have a choice. Instead, you might want to shift your focus to how you can page in/page out cells into your table to avoid costly calculations, or rearrange your cell to be able to calculate a better estimate. Another suggestion might be to implement estimatedHeightForRowAtIndexPath with an accurate but still less complex algorithm for calculating the height. In any event, a constant value for an estimatedRowHeight will likely never work when you support DynamicType. At the very least, you need to take into account the current DynamicType size.
Other than that, what can you do? Instead of using UITableViewAutomaticDimension, consider implementing heightForRowAtIndexPath and calculating the height of your displayed strings and caching the result (you could use an NSIndexPath -> NSNumber NSCache object). You need to cache the result because without an estimatedHeight, heightForRow is called once for every row when the table is loaded, and then once for every cell as it appears on screen. When using estimatedHeight on iOS 8, estimatedHeight is called once for each cell on launch and heightForRow is called as the cells appear. This is where the estimate is critical, because that is what's used to calculate the contentSize of the UITableView's backing UIScrollView. If the estimated size is wrong, the contentSize is wrong, and so when you ask the tableView to scroll to the last cell, the frame of the last cell is calculated with the bad estimate, which gives you the incorrect contentOffset. Unfortunately, I believe (based on the behavior I see trying to reproduce your question) that when you use UITableViewAutomaticDimension without an estimate, the runtime implicitly estimates an estimate.

EstimatedRowHeight is used by UIKit to estimate whole contentSize (and scrollIndicatorInset), so if you add new row at the end with automatic row dimension, you have to reset estimatedRowHeight to actual average value of whole tableView before you animate scroll.
This is not so good solution because its lot easier to count row height in old style - manually. Or add new cell height value to old table view's content height.
But because you adding new row at the end of table, you can scroll at middle or top position, which ends up with the bottom position, because there is contentInset.bottom = 0. And also the animation will look better.
So:
commentsFeed.scrollToRowAtIndexPath(indexPath, atScrollPosition: .**Middle**, animated: true)
Its look like in .Middle and .Top positions is some condition under the hood in animation which prevent to make a gap between bottom edge and table view's content (+ contentInset.bottom)
P.S. Why not to use the manuall row height calculation?
Because there is autolayout and I believe the "auto" is shortcut for automatic. And also it will save your time and troubles with custom fonts, attributed string, combined cell with more labels and other subviews and so on..

Try this, works for me:
NSMutableArray *visibleCellIndexPaths = [[self.tableView indexPathsForVisibleRows] mutableCopy];
[self.tableView beginUpdates];
[self.tableView reloadRowsAtIndexPaths:visibleCellIndexPaths withRowAnimation:UITableViewRowAnimationNone];
[self.tableView endUpdates];
[self.tableView scrollRectToVisible:CGRectMake(0, self.tableView.contentSize.height - self.tableView.bounds.size.height, self.tableView.bounds.size.width, self.tableView.bounds.size.height) animated:YES];

Related

UITableView scrolls to top when reloading cells with changing cell heights

I have a table view which contains a placeholders while it loads in images. When the image is loaded, I call reloadRowsAtIndexPaths:withRowAnimation:. At this point, the cell changes height, based on the size of the image. When that happens, I want the table view's content offset to remain in place, and for the cells below to be pushed further down, as you might imagine.
The effect I'm getting instead is that the scroll view scrolls back to the top. I'm not sure why this is, and I can't seem to prevent it. Putting beginUpdates() before and endUpdates()after the reloadRows line has no effect.
I am using estimatedRowHeight, as is needed as my table view can potentially have hundreds of rows of different heights. I am also implementing tableView:heightForRowAtIndexPath:.
EDIT: I've set up a demo project to test this, and admittedly I can't get the demo project to reproduce this effect. I'll keep working at it.
It's an issue with the estimatedRowHeight.
The more the estimatedRowHeight differs from the actual height, the more the table may jump when it is reloaded, especially the further down it has been scrolled. This is because the table's estimated size radically differs from its actual size, forcing the table to adjust its content size and offset.
The easiest workaround is to use a really accurate estimate. If the height per row varies greatly, determine the median height for a row, and use that as the estimate.
Always update the UI on the main thread. So just place
[self.tableView reloadData];
inside a main thread:
dispatch_async(dispatch_get_main_queue(), ^{
//UI Updating code here.
[self.tableView reloadData];
});
I had the same problem and decide it by this way: save heights of cells when they loads and give exact value in tableView:estimatedHeightForRowAtIndexPath:
// declare cellHeightsDictionary
NSMutableDictionary *cellHeightsDictionary;
// save height
- (void)tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath {
[cellHeightsDictionary setObject:#(cell.frame.size.height) forKey:indexPath];
}
// give exact height value
- (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath {
NSNumber *height = [cellHeightsDictionary objectForKey:indexPath];
if (height) return height.doubleValue;
return UITableViewAutomaticDimension;
}
I was seeing this, and the fix that worked for me was to choose an estimated row height that is the smallest of the possible rows. It had originally been set to the largest possible row height when the unintended scrolling was happening. I am just using the single tableView.estimatedRowHeight property, not the delegate method.

UITableView not scrolling all the way to top after resorting dynamic size cells and setting content offset to top

I have a tableview with dynamically sized cells, and a button that toggles the sort order of these cells. I'd like to scroll to the top every time the sort order is toggled, but when I set the content offset to the top, it seems to only scroll ~90% of the way there.
The offsetting code is simple enough and has served me well on different projects, so I seriously doubt the problem is here:
- (void) scrollToTop
{
CGPoint offset = CGPointMake(0, -self.tableView.contentInset.top);
[self.tableView setContentOffset:offset animated:YES];
}
[self.tableView reloadData]; // Lets update with whatever info we have
[self.tableView scrollToRowAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:0] atScrollPosition:UITableViewScrollPositionTop animated:NO];
reloading and then scrolling resolved for me.
Didn't think I would find the answer so soon.
I was using UITableView's tableView:estimatedHeightForRowAtIndexPath: to return my minimum cell height, and it seems that the tableview uses this inside reloadData to create an idea of how big the content is before actually dequeuing the cells and caching their height. Being halfway down the content and reloading the data causes the tableview to think the distance to the top is the (number of cells offscreen above the current visible * the minimum height from estimatedHeightForRow), causing the tableview to only offset itself as if all cells were the minimum height. My solution was just to avoid using the estimated height, since my tableview isn't excessively long anyway. If you do have a large tableview (approaching 1000+ rows) that actually needs to use the estimated values for performance reasons, you might want to find a way to make the estimated values as close to the runtime values as possible, or look into more detailed solutions.
tl;dr - Remove tableView:estimatedHeightForRowAtIndexPath: and just allow the tableView to size itself from heightForRowAtIndexPath
What about something like this instead?
NSIndexPath *start = = [NSIndexPath indexPathForRow:0 inSection:0];
[self.tableView scrollToRowAtIndexPath:start atScrollPosition:UITableViewScrollPositionTop animated:YES];
None of these worked for me. The solution was to call layoutIfNeeded() before setting the content offset:
tableView.reloadData();
tableView.layoutIfNeeded();
tableView.setContentOffset(CGPoint.Empty, animated: true);

How to layout custom UITableViewCell with varying height

I have a UITableViewCell subclass which has an image, title and description.
I am supposed to resize the cell height according to the description content length i.e. if it spans more than 5 lines, I should extend it (+other subviews like image etc) till it lasts.
The next coming cells should begin only after that.
I have my UITableViewCell subclass instantiated from xib which has a fixed row height = 160.
I know this is pretty standard requirement but I am unable to find any guidelines.
I already extended layoutSubViews like this:
- (void) layoutSubviews
{
[self resizeCellImage];
}
- (void) resizeCellImage
{
CGRect descriptionRect = self.cellDescriptionLabel.frame;
CGRect imageRect = self.cellImageView.frame;
float descriptionBottomEdgeY = descriptionRect.origin.y + descriptionRect.size.height;
float imageBottomEdgeY = imageRect.origin.y + imageRect.size.height;
if (imageBottomEdgeY >= descriptionBottomEdgeY)
return;
//push the bottom of image to the bottom of description
imageBottomEdgeY = descriptionBottomEdgeY;
float newImageHeight = imageBottomEdgeY - imageRect.origin.y;
imageRect.size.height = newImageHeight;
self.cellImageView.frame = imageRect;
CGRect cellFrame = self.frame;
cellFrame.size.height = imageRect.size.height + imageRect.origin.y + 5;
CGRect contentFrame = self.contentView.frame;
contentFrame.size.height = cellFrame.size.height - 1;
self.contentView.frame = contentFrame;
self.frame = cellFrame;
}
It pretty much tells that if description is taller than image, we must resize the image as well as cell height to fit the description.
However when I invoke this code by doing this:
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
cell.cellDescriptionLabel.text = #"Some long string";
[cell.cellDescriptionLabel sizeToFit];
[cell setNeedsLayout];
return cell;
}
It appears that while cell frame is changed due to layoutSubViews call, other cells do not respect it. That is, they appear on the same position had the previous cell would not have resized itself.
Two questions:
How to make it possible what I want?
Am I doing right by calling setNeedsLayout within cellForRowAtIndexPath?
P.S.: I know heightForRowAtIndexPath holds key to changing the cell height, but I feel that the data parsing (not shown here) that I do as part of cellForRowAtIndexPath would be an overkill just to calculate height. I need something that can directly tell the UITableViewCell to resize itself according to content needs.
-tableView:heightForRowAtIndexPath: is by design how variable sized cells are calculated. The actual frame of a cell is of no importance and is changed by the table view to fit its needs.
You are sort of thinking of this backwards. The delegate tells the table view how cells need to be drawn, then the table view forces cells to fit those characteristics. The only thing you need to provide to the cell is the data it needs to hold.
This is because a table view calculates all the heights of all the cells before it has any cells to draw. This is done to allow a table view to size it's scroll view correctly. It allows for properly sized scroll bars and smooth quick-pans through the table view. Cells are only requested when a table view thinks a cell needs to be displayed to the screen.
UPDATE: How Do I Get Cell Heights
I've had to do this a couple of times. I have my view controller keep a cell which is never used in the table view.
#property (nonatomic) MyTableViewCell *standInCell;
I then use this cell as a stand in when I need measurements. I determine the base height of the cell without the variable sized views.
#property (nonatomic) CGFloat standInCellBaseHeight;
Then in -tableView:heightForRowAtIndexPath:, I get the height for all my variable sized views with the actual data for that index path. I add the variable sized heights to my stand in cell base height. I return that new calculated height.
Note, this is all non-autolayout. I'm sure the approach would be similar, but not identical to this, but I have no experience.
-tableView:heightForRowAtIndexPath: is the preferred way to tell tableview the size of its cells. You may either precalculate and cache it in a dictionary and reuse, or alternatively in ios7, you can use -tableView:estimatedHeightForRowAtIndexPath: to estimate the sizes.
Take a look at this thread - https://stackoverflow.com/questions/18746929/using-auto-layout-in-uitableview-for-dynamic-cell-layouts-variable-row-heights, the answer points to a very good example project here - https://github.com/caoimghgin/TableViewCellWithAutoLayout.
Sorry, but as far as I know you have to implement tableView:heightForRowAtIndexPath:. Warning, in iOS 6 this gets called on every row in you UITableView right away, I think to draw the scrollbar. iOS7 introduces tableView:estimatedHeightForRowAtIndexPath: which if implemented allows you to just guess at the height before doing all the calculation. This can help out a lot on very large tables.
What I found works well is just have your tableView:heightForRowAtIndexPath: call cellForRowAtIndexPath: to get the cell for that row, and then query that cell for it's height cell.bounds.size.height and return that.
This works pretty well for small tables or in iOS7 with tableView:estimatedHeightForRowAtIndexPath implemented.

custom cell height based on objects inside the cell

I made a custom cell, placed a UITextView inside it and I want to change the height of the cell based on the length of UITextView's text length. I know how to statically change the cell height using heightForRowAtIndexPath, but I can't put my head around doing it dynamically, based on content.
I have read about a dozen topics on this forum and on several other, but I didn't find the answer I was looking for.
Any ideas?
in heightForRowAtIndexPath
float height = [cell.textView.text sizeWithFont:cell.textView.font constrainedToSize:CGSizeMake(cell.textView.frame.size.width, 10000)].height;
return height;
10000 it's max height of cell, actually you can set max integer value
In your textViewDidChange method, call
[tableView beginUpdates];
[tableView endUpdates];
This will trigger heightForRowAtIndexPath to recalculate all your cell sizes each time the user types a letter. Then in heightForRowAtIndexPath, you can then calculate the necessary size for your cell.
The sizeWithFont method will return the size needed to display the text in a UILabel, which is slightly different than that for a UITextView due to content insets, line spacing, etc. I've used a somewhat hacky solution in the past. If you create a temporary UITextView, set it's text, and use [UIView sizeThatFits:constraintSize] to get the size that will fit all its content within the constraints. (The documentation on this method is a little unclear - take a look at this answer for more info: question about UIView's sizeThatFits)
UITextView *temp = [UITextView alloc] initWithFrame:someArbitraryFrame];
temp.font = DISPLAY_FONT
temp.text = cell.textView.text;
//This gets the necessary size to fit the view's content within the specified constraints
CGSize correctSize = [self.temp sizeThatFits:CGSizeMake(CONSTRAINT_WIDTH, CONSTRAINT_HEIGHT)];
return correctSize.height
In interest of efficiency, you should probably store/lazy-load the temporary textView so that you're not creating a new UITextView for every cell, but rather re-using the same one to calculate height for different text.

iOS the right way of heightForRowAtIndexPath calculation

I want to learn a common and right way of calculation of height for custom cells.
My cells are loaded from nib, they have two multiline UILabels one above other.
At the moment I create special configuration cell in viewDidLoad and use it in heightForRowAtIndexPath.
-(CGFloat) tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
[self configureCell:self.configurationCell forIndexPath:indexPath];
CGRect configFrame = self.configurationCell.frame;
configFrame.size.width = self.view.frame.size.width;
self.configurationCell.frame = configFrame;
[self.configurationCell layoutSubviews];
UILabel *label = (UILabel *)[self.configurationCell viewWithTag:2];
float height = label.frame.origin.y + label.frame.size.height + 10;
return height;
}
It works but seems to be a bad manner. I think that heights must be precalculated for each item and for each orientation. But I can't find a way to make it nice and straightforward.
Could you help me?
Cell Label's style (like font and offsets from the screen borders) must be loaded from nib file (cell is inside nib).
Added cell's layourSubviews method:
-(void) layoutSubviews {
[super layoutSubviews];
[self.shortDescriptionLabel resize];
CGRect longDescriptionFrame = self.longDescriptionLabel.frame;
longDescriptionFrame.origin.y = self.shortDescriptionLabel.frame.origin.y + self.shortDescriptionLabel.frame.size.height + 5;
self.longDescriptionLabel.frame = longDescriptionFrame;
[self.longDescriptionLabel resize];
}
resize method of label simply increases it's height to fit all the text. So height of cell is calculated as longDescriptionLabel.bottom + 10. Simple code but not very beautiful.
It appears that you are trying to create subviews inside heightForRowAtIndexPath. View creation is supposed to be done in cellForRowAtIndexPath.
According to your implementation, you can only determine the height of the cell after it's been laid out. This is no good because UITableView calls heightForRowAtIndexPath for every cell, not just the visible ones upon data reload. As a result, subviews of all cells are created even if they aren't required to be visible.
To optimize this implementation, you have to work out some kind of formula to allow determination of height without laying out views. Even if your layout is elastic or has variable height, given text rectangle, you can still determine its height. Use [NSString sizeWithFont:] to determine its displayed rectangle. Record this information in your data delegate. When heightForRowAtIndexPath is called, return it directly.

Resources