I am using iOS 8 new self-sizing cells. Visually it works good - each cell gets its right size. However, if I try to scroll to the last row, the table view doesn't seem to know its right size. Is this a bug or is there a fix for that?
Here's how to recreate the problem:
Using this project - TableViewCellWithAutoLayoutiOS8 (referenced from this SO answer), I got the auto-resizing cells as expected.
However, if I am calling the scrollToRowAtIndexPath function, like this:
tableView.scrollToRowAtIndexPath(NSIndexPath(forRow: model.dataArray.count - 1, inSection: 0), atScrollPosition: .Bottom, animated: true)
I do not get to the last row - It only gets me around halfway there.
Even by trying to use a lower level function like this:
tableView.setContentOffset(CGPointMake(0, tableView.contentSize.height - tableView.frame.size.height), animated: true)
The result is not as expected, it won't get to the end. If I click it a lot of times or wait a few moments, eventually it will get to the right place. It seems the tableView.contentSize.height is not set correctly, so the iOS "doesn't know" where that last cell is.
Would appreciate any help.
Thanks
Update: Jun 24, 2015
Apple has addressed most of these bugs as of the iOS 9.0 SDK. All of the issues are fixed as of iOS 9 beta 2, including scrolling to the top & bottom of the table view without animation, and calling reloadData while scrolled in the middle of the table view.
Here are the remaining issues that have not been fixed yet:
When using a large estimated row height, scrolling to the last row with animation causes the table view cells to disappear.
When using a small estimated row height, scrolling to the last row with animation causes the table view to finish scrolling too early, leaving some cells below the visible area (and the last row still offscreen).
A new bug report (rdar://21539211) has been filed for these issues relating to scrolling with animation.
Original Answer
This is an Apple bug with the table view row height estimation, and it has existed since this functionality first was introduced in iOS 7. I have worked directly with Apple UIKit engineers and developer evangelists on this issue -- they have acknowledged that it is a bug, but do not have any reliable workaround (short of disabling row height estimation), and did not seem particularly interested in fixing it.
Note that the bug manifests itself in other ways, such as disappearing table view cells when you call reloadData while scrolled partially or fully down (e.g. contentOffset.y is significantly greater than 0).
Clearly, with iOS 8 self sizing cells, row height estimation is critically important, so Apple really needs to address this ASAP.
I filed this issue back on Oct 21 2013 as Radar #15283329. Please do file duplicate bug reports so that Apple prioritizes a fix.
You can attach this simple sample project to demonstrate the issue. It is based directly on Apple's own sample code.
This has been a very annoying bug, but I think I found a permanent solution, though I cannot fully explain why.
Call the function after a tiny (unnoticed) delay:
let delay = 0.1 * Double(NSEC_PER_SEC)
let time = dispatch_time(DISPATCH_TIME_NOW, Int64(delay))
dispatch_after(time, dispatch_get_main_queue(), {
tableView.scrollToRowAtIndexPath(NSIndexPath(forRow: model.dataArray.count - 1, inSection: 0), atScrollPosition: .Bottom, animated: true)
})
Do tell me if this works for you as well.
It is definitely a bug from Apple. I also have this problem. I solved this problem by calling "scrollToRowAtIndexPath" method twice example code is:
if array.count > 0 {
let indexPath: NSIndexPath = NSIndexPath(forRow: array.count - 1, inSection: 0)
self.tblView.scrollToRowAtIndexPath(indexPath, atScrollPosition: .Bottom, animated: true)
let delay = 0.1 * Double(NSEC_PER_SEC)
let time = dispatch_time(DISPATCH_TIME_NOW, Int64(delay))
dispatch_after(time, dispatch_get_main_queue(), {
self.tblView.scrollToRowAtIndexPath(indexPath, atScrollPosition: .Bottom, animated: true)
})
}
I found a temporary workaround that might be helpful until Apple decides to fixes the many bugs that have been plaguing us.
- (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath
{
NSString *text = [self findTextForIndexPath:indexPath];
UIFont *font = [UIFont fontWithName:#"HelveticaNeue" size:13];
CGRect estimatedHeight = [text boundingRectWithSize:CGSizeMake(215, MAXFLOAT)
options:NSStringDrawingUsesLineFragmentOrigin
attributes:#{NSFontAttributeName: font}
context:nil];
return TOP_PADDING + CGRectGetHeight(estimatedHeight) + BOTTOM_PADDING;
}
This is not perfect, but it did the job for me. Now I can call:
- (void)scrollToLastestSeenMessageAnimated:(BOOL)animated
{
NSInteger count = [self tableView:self.tableView numberOfRowsInSection:0];
if (count > 0) {
NSInteger lastPos = MAX(0, count-1);
[self.tableView scrollToRowAtIndexPath:[NSIndexPath indexPathForItem:lastPos inSection:0] atScrollPosition:UITableViewScrollPositionBottom animated:animated];
}
}
On viewDidLayoutSubviews and it finds the correct place on the bottom (or a very close estimated position).
I hope that helps.
For my case, I found a temporary workaround by not suggesting an estimated cell height to the program. I did this by commenting out the following method in my code:
- (CGFloat) tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath
However, please take note that doing so may affect the user experience when the user scrolls, if your cells varies a lot compared to each other. For my case, no noticeable difference so far.
Hope it helps!
I have this problem in Swift 5 iOS 13 yet, this solved my problem
DispatchQueue.main.async { [weak self] in
self?.tableView.reloadData()
self?.tableView.scrollToRow(at: indexPath, at: .middle, animated: false)
}
My solution was to use the size of the storyboard as the estimate.
So instead of this:
- (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath {
return UITableViewAutomaticDimension;
}
I did something like this:
- (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath {
MyMessageType messageType = [self messageTypeForRowAtIndexPath:indexPath];
switch (messageType) {
case MyMessageTypeText:
return 45;
break;
case MyMessageTypeMaybeWithSomeMediaOrSomethingBiggerThanJustText:
return 96;
break;
default:
break;
}
}
I'm writing a chat table view so it is likely that many of my cells, specifically that text type will be larger than what is in IB, especially if the chat message is very long. This seems to be a pretty good...well...estimate and scrolling to the bottom gets pretty close. It seems to be slightly worse as the scrolling gets longer, but that is to be expected I suppose
Just call tableview reloadData after viewDidAppear can solve the problem
-(void)viewDidAppear:(BOOL)animated
{
[super viewDidAppear:animated];
[self.tableView reloadData];
}
Although as smileyborg's answer it is bug in iOS 8.x, it should be fixed in all platforms which you supports...
To workaround on pre-iOS9, below code do the trick without any dispatch_async or dispatch_after.
Tested on iOS 8.4 simulator.
UPDATE: Calling (only) layoutIfNeeded does not work when view controller become visible by UIPageViewController being scrolled. So use layoutSubviews (or maybe setNeedsLayout + layoutIfNeeded) instead.
// For iOS 8 bug workaround.
// See https://stackoverflow.com/a/33515872/1474113
- (void)scrollToBottomForPreiOS9
{
CGFloat originalY, scrolledY;
do {
// Lay out visible cells immediately for current contentOffset.
// NOTE: layoutIfNeeded does not work when hosting UIPageViewController is dragged.
[self.tableView layoutSubviews];
originalY = self.tableView.contentOffset.y;
[self scrollToBottom]; // Call -scrollToRowAtIndexPath as usual.
scrolledY = self.tableView.contentOffset.y;
} while (scrolledY > originalY);
}
I had the same problem when creating a chat tableView with different height of cells. I call the code below in viewDidAppear() lifecycle method:
// First figure out how many sections there are
let lastSectionIndex = self.tableView.numberOfSections - 1
// Then grab the number of rows in the last section
let lastRowIndex = self.tableView.numberOfRowsInSection(lastSectionIndex) - 1
// Now just construct the index path
let pathToLastRow = NSIndexPath(forRow: lastRowIndex, inSection: lastSectionIndex)
// Make the last row visible
self.tableView.scrollToRowAtIndexPath(pathToLastRow, atScrollPosition: UITableViewScrollPosition.None, animated: true)
Please let me know if that worked for you too.
From the storyboard window click in a blank area to deselect all views then click the view that has the table view in it and then click the Resolve Auto Layout Issue icon and select Reset to Suggested Constraints
Use this simple code to scroll bottom
var rows:NSInteger=self.tableName.numberOfRowsInSection(0)
if(rows > 0)
{
let indexPath = NSIndexPath(forRow: rows-1, inSection: 0)
tableName.scrollToRowAtIndexPath(indexPath , atScrollPosition: UITableViewScrollPosition.Bottom, animated: true)
}
}
Related
How can one insert rows into a UITableView at the very top without causing the rest of the cells to be pushed down - so the scroll position does not appear to change at all?
Just like Facebook and Twitter when new posts are delivered, they are inserted at the top but the scroll position remains fixed.
My question is similar to this this question. What makes my question unique from that question is that I'm not using a table with fixed row heights - I'm using UITableViewAutomaticDimension and an estimatedRowHeight. Therefore the answers suggested there will not work because I cannot determine the row height.
I have tried this solution that doesn't involve taking row height into consideration, but the contentSize is still not correct after reloading, because the contentOffset set isn't the same relative position - the cells are still pushed down past where they were before the insert. This is because the cell hasn't been rendered on screen so iOS doesn't bother to calculate the appropriate height for it until it's about to appear, therefore contentSize is not accurate.
CGSize beforeContentSize = tableView.contentSize;
[tableView reloadData];
CGSize afterContentSize = tableView.contentSize;
CGPoint afterContentOffset = tableView.contentOffset;
tableView.contentOffset = CGPointMake(afterContentOffset.x, afterContentOffset.y + afterContentSize.height - beforeContentSize.height);
Alain pointed out rectForCellAtIndexPath which forces iOS to calculate the appropriate height. I can now determine the proper height for the inserted cells, but the scroll view's contentSize is still not correct, as is evidenced when I iterate over all cells and add up the heights which is a larger than contentSize.height. So ultimately when I set the contentOffset manually it's not scrolling to the correct location.
Original code:
//update data source
[tableView beginUpdates];
[tableView insertRowsAtIndexPaths:newIndexPaths withRowAnimation:UITableViewRowAnimationAutomatic];
[tableView endUpdates];
In this scenario, what can be done to achieve the desired behavior?
Late to the party but this works even when cell have dynamic heights (a.k.a. UITableViewAutomaticDimension), no need to iterate over cells to calculate their size, but works only when items are added at the very beginning of the tableView and there is no header, with a little bit of math it's probably possible to adapt this to every situation:
func tableView(tableView: UITableView, willDisplayCell cell: UITableViewCell, forRowAtIndexPath indexPath: NSIndexPath) {
if indexPath.row == 0 {
self.getMoreMessages()
}
}
private func getMoreMessages(){
var initialOffset = self.tableView.contentOffset.y
self.tableView.reloadData()
//#numberOfCellsAdded: number of items added at top of the table
self.tableView.scrollToRowAtIndexPath(NSIndexPath(forRow: numberOfCellsAdded, inSection: 0), atScrollPosition: .Top, animated: false)
self.tableView.contentOffset.y += initialOffset
}
Also, single row sections can accomplish desired effect
If a section already exists at the specified index location, it is moved down one index location.
https://developer.apple.com/library/ios/documentation/UIKit/Reference/UITableView_Class/index.html#//apple_ref/occ/instm/UITableView/insertSections:withRowAnimation:
let idxSet = NSIndexSet(index: 0)
self.verseTable.insertSections(idxSet, withRowAnimation: .Fade)
I think your solution was almost there. You should have based the new Y offset on the "before" Y offset rather than the "after".
..., beforeContentOffset.y + afterContentSize.height - beforeContentSize.height
(you would need to save the beforeContentOffset point before the reload data though)
Meet the same problem and still do not find a elegant way to solve it.Here is my way,suppose I want to insert moreData.count cells above the current cell.
// insert the new data to self.data
for (NSInteger i = moreData.count-1; i >= 0; i--) {
[self.data insertObject:moreData[i] atIndex:0];
}
//reload data or insert row at indexPaths
[self.tableView reloadData];
//after 0.1s invoke scrollToRowAtIndexPath:atScrollPosition:animated:
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[self.tableView scrollToRowAtIndexPath:[NSIndexPath indexPathForRow:moreData.count inSection:0] atScrollPosition:UITableViewScrollPositionTop animated:NO];
});
The advantage is fixed the position before and after inserting new data.The disadvantage is we can see the screen flash (the topmost cell shows and move up)
My working solution for iOS 13 & Swift 5:
Note: Only tested with dynamic cell heights (UITableViewAutomaticDimension / UITableView. automaticDimension).
P.S. None of the solutions posted here did work for me.
extension UITableView {
/**
Method to use whenever new items should be inserted at the top of the table view.
The table view maintains its scroll position using this method.
- warning: Make sure your data model contains the correct count of items before invoking this method.
- parameter itemCount: The count of items that should be added at the top of the table view.
- note: Works with `UITableViewAutomaticDimension`.
- links: https://bluelemonbits.com/2018/08/26/inserting-cells-at-the-top-of-a-uitableview-with-no-scrolling/
*/
func insertItemsAtTopWithFixedPosition(_ itemCount: Int) {
layoutIfNeeded() // makes sure layout is set correctly.
var initialContentOffSet = contentOffset.y
// If offset is less than 0 due to refresh up gesture, assume 0.
if initialContentOffSet < 0 {
initialContentOffSet = 0
}
// Reload, scroll and set offset:
reloadData()
scrollToRow(
at: IndexPath(row: itemCount, section: 0),
at: .top,
animated: false)
contentOffset.y += initialContentOffSet
}
}
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];
tldr; Auto constrains appear to break on push segue and return to view for custom cells
Edit: I have provided a github example project that shows off the error that occurs
https://github.com/Matthew-Kempson/TableViewExample.git
I am creating an app which requires the title label of the custom UITableCell to allow for varying lines dependent on the length of the post title. The cells load into the view correctly but if I press on a cell to load the post in a push segue to a view containing a WKWebView you can see, as shown in the screen shot, the cells move immediately to incorrect positions. This is also viewed when loading the view back through the back button of the UINavigationController.
In this particular example I pressed on the very end cell, with the title "Two buddies I took a picture of in Paris", and everything is loaded correctly. Then as shown in the next screenshot the cells all move upwards for unknown reasons in the background of loading the second view. Then when I load the view back you can see the screen has shifted upwards slightly and I cannot actually scroll any lower than is shown. This appears to be random as with other tests when the view loads back there is white space under the bottom cell that does not disappear.
I have also included a picture containing the constraints that the cells has.
Images (I need more reputation to provide images in this question apparently so they are in this imgur album): http://imgur.com/a/gY87E
My code:
Method in custom cell to allow the cell to resize the view correctly when rotating:
override func layoutSubviews() {
super.layoutSubviews()
self.contentView.layoutIfNeeded()
// Update the label constaints
self.titleLabel.preferredMaxLayoutWidth = self.titleLabel.frame.width
self.detailsLabel.preferredMaxLayoutWidth = self.detailsLabel.frame.width
}
Code in tableview
override func viewDidLoad() {
super.viewDidLoad()
// Create and register the custom cell
self.tableView.estimatedRowHeight = 56
self.tableView.rowHeight = UITableViewAutomaticDimension
}
Code to create the cell
override func tableView(tableView: UITableView!, cellForRowAtIndexPath indexPath: NSIndexPath!) -> UITableViewCell! {
if let cell = tableView.dequeueReusableCellWithIdentifier("LinkCell", forIndexPath: indexPath) as? LinkTableViewCell {
// Retrieve the post and set details
let link: Link = self.linksArray.objectAtIndex(indexPath.row) as Link
cell.titleLabel.text = link.title
cell.scoreLabel.text = "\(link.score)"
cell.detailsLabel.text = link.stringCreatedTimeIntervalSinceNow() + " ago by " + link.author + " to /r/" + link.subreddit
return cell
}
return nil
}
If you require any more code or information please ask and I shall provide what is necessary
Thanks for your help!
This bug is caused by having no tableView:estimatedHeightForRowAtIndexPath: method. It's an optional part of the UITableViewDelegate protocol.
This isn't how it's supposed to work. Apple's documentation says:
Providing an estimate the height of rows can improve the user experience when loading the table view. If the table contains variable height rows, it might be expensive to calculate all their heights and so lead to a longer load time. Using estimation allows you to defer some of the cost of geometry calculation from load time to scrolling time.
So this method is supposed to be optional. You'd think if you skipped it, it would fall back on the accurate tableView:heightForRowAtIndexPath:, right? But if you skip it on iOS 8, you'll get this behaviour.
What seems to be happening? I have no internal knowledge, but it looks like if you do not implement this method, the UITableView will treat that as an estimated row height of 0. It will compensate for this somewhat (and, at least in some cases, complain in the log), but you'll still see an incorrect size. This is quite obviously a bug in UITableView. You see this bug in some of Apple's apps, including something as basic as Settings.
So how do you fix it? Provide the method! Implement tableView: estimatedHeightForRowAtIndexPath:. If you don't have a better (and fast) estimate, just return UITableViewAutomaticDimension. That will fix this bug completely.
Like this:
- (CGFloat)tableView:(UITableView *)tableView
estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath {
return UITableViewAutomaticDimension;
}
There are potential side effects. You're providing a very rough estimate. If you see consequences from this (possibly cells shifting size as you scroll), you can try to return a more accurate estimate. (Remember, though: estimate.)
That said, this method is not supposed to return a perfect size, just a good enough size. Speed is more important than accuracy. And while I spotted a few scrolling glitches in the Simulator there were none in any of my apps on the actual device, either the iPhone or iPad. (I actually tried writing a more accurate estimate. But it's hard to balance speed and accuracy, and there was simply no observable difference in any of my apps. They all worked exactly as well as just returning UITableViewAutomaticDimension, which was simpler and was enough to fix the bug.)
So I suggest you do not try to do more unless more is required. Doing more if it is not required is more likely to cause bugs than fix them. You could end up returning 0 in some cases, and depending on when you return it that could lead to the original problem reappearing.
The reason Kai's answer above appears to work is that it implements tableView:estimatedHeightForRowAtIndexPath: and thus avoids the assumption of 0. And it does not return 0 when the view is disappearing. That said, Kai's answer is overly complicated, slow, and no more accurate than just returning UITableViewAutomaticDimension. (But, again, thanks Kai. I'd never have figured this out if I hadn't seen your answer and been inspired to pull it apart and figure out why it works.)]
Note that you may also need to force layout of the cell. You'd think iOS would do this automatically when you return the cell, but it doesn't always. (I will edit this once I investigate a bit more to figure out when you need to do this.)
If you need to do this, use this code before return cell;:
[cell.contentView setNeedsLayout];
[cell.contentView layoutIfNeeded];
The problem of this behavior is when you push a segue the tableView will call the estimatedHeightForRowAtIndexPath for the visible cells and reset the cell height to a default value. This happens after the viewWillDisappear call. If you come back to TableView all the visible cells are messed up..
I solved this problem with a estimatedCellHeightCache. I simply add this code snipped to the cellForRowAtIndexPath method:
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
...
// put estimated cell height in cache if needed
if (![self isEstimatedRowHeightInCache:indexPath]) {
CGSize cellSize = [cell systemLayoutSizeFittingSize:CGSizeMake(self.view.frame.size.width, 0) withHorizontalFittingPriority:1000.0 verticalFittingPriority:50.0];
[self putEstimatedCellHeightToCache:indexPath height:cellSize.height];
}
...
}
Now you have to implement the estimatedHeightForRowAtIndexPath as following:
-(CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath {
return [self getEstimatedCellHeightFromCache:indexPath defaultHeight:41.5];
}
Configure the Cache
Add this property to your .h file:
#property NSMutableDictionary *estimatedRowHeightCache;
Implement methods to put/get/reset.. the cache:
#pragma mark - estimated height cache methods
// put height to cache
- (void) putEstimatedCellHeightToCache:(NSIndexPath *) indexPath height:(CGFloat) height {
[self initEstimatedRowHeightCacheIfNeeded];
[self.estimatedRowHeightCache setValue:[[NSNumber alloc] initWithFloat:height] forKey:[NSString stringWithFormat:#"%d", indexPath.row]];
}
// get height from cache
- (CGFloat) getEstimatedCellHeightFromCache:(NSIndexPath *) indexPath defaultHeight:(CGFloat) defaultHeight {
[self initEstimatedRowHeightCacheIfNeeded];
NSNumber *estimatedHeight = [self.estimatedRowHeightCache valueForKey:[NSString stringWithFormat:#"%d", indexPath.row]];
if (estimatedHeight != nil) {
//NSLog(#"cached: %f", [estimatedHeight floatValue]);
return [estimatedHeight floatValue];
}
//NSLog(#"not cached: %f", defaultHeight);
return defaultHeight;
}
// check if height is on cache
- (BOOL) isEstimatedRowHeightInCache:(NSIndexPath *) indexPath {
if ([self getEstimatedCellHeightFromCache:indexPath defaultHeight:0] > 0) {
return YES;
}
return NO;
}
// init cache
-(void) initEstimatedRowHeightCacheIfNeeded {
if (self.estimatedRowHeightCache == nil) {
self.estimatedRowHeightCache = [[NSMutableDictionary alloc] init];
}
}
// custom [self.tableView reloadData]
-(void) tableViewReloadData {
// clear cache on reload
self.estimatedRowHeightCache = [[NSMutableDictionary alloc] init];
[self.tableView reloadData];
}
I had the exact same problem. The table view had several different cell classes, each of which was a different height. Moreover, one of the cells classes had to show additional text, meaning further variation.
Scrolling was perfect in most situations. However, the same problem described in the question manifested. That was, having selected a table cell and presented another view controller, on return to the original table view, the upwards scrolling was extremely jerky.
The first line of investigation was to consider why data was being reloaded at all. Having experimented, I can confirm that on return to the table view, data is reloaded, albeit not using reloadData.
See my comment ios 8 tableview reloads automatically when view appears after pop
With no mechanism to deactivate this behaviour, the next line of approach was to investigate the jerky scrolling.
I came to the conclusion that the estimates returned by estimatedHeightForRowAtIndexPath are an estimated precalculation. Log to console out the estimates and you'll see that the delegate method is queried for every row when the table view first appears. That's before any scrolling.
I quickly discovered that some of the height estimate logic in my code was badly wrong. Resolving this fixed the worst of the jarring.
To achieve perfect scrolling, I took a slightly different approach to the answers above. The heights were cached, but the values used were from the actual heights that would have been captured as the user scrolls downwards:
var myRowHeightEstimateCache = [String:CGFloat]()
To store:
func tableView(tableView: UITableView, didEndDisplayingCell cell: UITableViewCell, forRowAtIndexPath indexPath: NSIndexPath) {
myRowHeightEstimateCache["\(indexPath.row)"] = CGRectGetHeight(cell.frame)
}
Using from the cache:
func tableView(tableView: UITableView, estimatedHeightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat
{
if let height = myRowHeightEstimateCache["\(indexPath.row)"]
{
return height
}
else
{
// Not in cache
... try to figure out estimate
}
Note that in the method above, you will need to return some estimate, as that method will of course be called before didEndDisplayingCell.
My guess is that there is some sort of Apple bug underneath all of this. That's why this issue only manifests in an exit scenario.
Bottom line is that this solution is very similar to those above. However, I avoid any tricky calculations and make use of the UITableViewAutomaticDimension behaviour to just cache the actual row heights displayed using didEndDisplayingCell.
TLDR: work around what's most likely a UIKit defect by caching the actual row heights. Then query your cache as the first option in the estimation method.
Well, until it works, you can delete those two line:
self.tableView.estimatedRowHeight = 45
self.tableView.rowHeight = UITableViewAutomaticDimension
And add this method to your viewController:
override func tableView(tableView: UITableView!, heightForRowAtIndexPath indexPath: NSIndexPath!) -> CGFloat {
let cell = tableView.dequeueReusableCellWithIdentifier("cell") as TableViewCell
cell.cellLabel.text = self.tableArray[indexPath.row]
//Leading space to container margin constraint: 0, Trailling space to container margin constraint: 0
let width = tableView.frame.size.width - 0
let size = cell.cellLabel.sizeThatFits(CGSizeMake(width, CGFloat(FLT_MAX)))
//Top space to container margin constraint: 0, Bottom space to container margin constraint: 0, cell line: 1
let height = size.height + 1
return (height <= 45) ? 45 : height
}
It worked without any other changes in your test project.
If you have set tableView's estimatedRowHeight property.
tableView.estimatedRowHeight = 100;
Then comment it.
// tableView.estimatedRowHeight = 100;
It solved the bug which occurs in iOS8.1 for me.
If you really want to keep it,then you could force tableView to reloadData before pushing.
[self.tableView reloadData];
[self.navigationController pushViewController:vc animated:YES];
or do it in viewWillDisappear:.
- (void)viewWillDisappear:(BOOL)animated {
[super viewWillDisappear:animated];
[self.tableView reloadData];
}
Hope it helps.
In xcode 6 final for me the workaround does not work. I am using custom cells and dequeing a cell in heightForCell leads to infinity loop. As dequeing a cell calls heightForCell.
And still the bug seems to be present.
If none of the above worked for you (as it happened to me) just check the estimatedRowHeight property from the table view is kind of accurate. I checked I was using 50 pixels when it was actually closer to 150 pixels. Updating this value fixed the issue!
tableView.rowHeight = UITableViewAutomaticDimension
tableView.estimatedRowHeight = tableViewEstimatedRowHeight // This should be accurate.
When a user taps a button in one of my rows I am updating the underlying model for that row and then calling reloadRowsAtIndexPaths for the given row (i.e. single row reload).
- (IBAction)handleCompleteTouchEvent:(UIButton *)sender {
NSIndexPath *indexPath = [self.tableView indexPathForView:sender];
id item = [self dataForIndexPath:indexPath];
if ([item respondsToSelector:#selector(completed)]) {
// toogle completed value
BOOL completed = ![[item valueForKey:#"completed"] boolValue];
[item setValue:[NSNumber numberWithBool:completed] forKey:#"completed"];
[self.tableView beginUpdates];
[self.tableView reloadRowsAtIndexPaths:#[indexPath] withRowAnimation:UITableViewRowAnimationNone];
[self.tableView endUpdates];
}
}
The problem is that the table view bounces back to the top of the section after making this call. How can I prevent this from occurring and keep the scroll position where it is?
Ah Ha! I found the problem and am going to answer my own question for the poor soul who runs into this issue in the future.
All of my cells have variable height so I was using the new iOS7 method in UITableViewDelegate thinking it might speed up render time (not that I really needed it):
- (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath;
Anyway, implementing this method has the evil side effect of causing the table to bounce to the top of the section when calling:
[self.tableView reloadRowsAtIndexPaths:#[indexPath] withRowAnimation:UITableViewRowAnimationNone];
To solve the bounce problem I just removed the override of that estimatedHeightForRowAtIndexPath method and now everything works as it should. Happy at last.
This did the trick for me.
UIView.setAnimationsEnabled(false)
tableView.reloadRows(at: [...], with: .none)
UIView.setAnimationsEnabled(true)
Swift 4.2
This can work anywhere you want to remove animation.
During reload of table , table section or any row
UIView.performWithoutAnimation({
cell.configureSelection(isSelected: true)
tableView.reloadSections([1], with: .none)
tableView.allowsSelection = false
})
You should be able to do what you are trying to do by changing the cell contents directly. For example, if you are using the base UITableViewCell class and the data in your model is a NSString which you show in the table view cell, you can do the following (after you change your data model) instead of calling reloadRowsAtIndexPaths:
UITableViewCell *cell = [tableView cellForRowAtIndexPath:indexPath];
UILabel *label = [cell textLabel];
label.text = #"New Value";
If you are using a custom subclass of UITableViewCell, it's roughly the same except for accessing the views of the cell will need to be done through the contentView property.
It may be possible to put the cell in it's own section and call reload section:
[self.tableView reloadSections:[NSIndexSet indexSetWithIndex:1] withRowAnimation:UITableViewRowAnimationFade];
this appears to fix the issue partially. Not the cleanest way but it may work for you.
In my similar case, I had to tweak the implementation of method
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
...
}
where my heights were being reset. So, instead of resetting height for every cell I updated only the effected cells for reloadRowsAtIndexPaths call.
If you know the minimum height of your cell, you must specify that while setting estimatedRowHeight. I was setting it to 1 as I have read before somewhere that any value above 0 would suffice the purpose, but it was the culprit.
When I set it to 44, which was the minimum height my cell could have, all went fine.
Dynamic heights were also working fine, no issues with this fix.
To build off xsee's answer -
I had set the Estimate in interface builder to "automatic". I changed this to another number and it started working. I kept Row Height to automatic.
I had the same issue. I ended up just calling tableView.reloadData() instead after updating my data / cell and it didn't bounce back to the top / data was updated in place - FYI
This solved this issue for me with Swift 5 / iOS 14:
UIView.performWithoutAnimation({
self.tableView.reloadRows(at: [self.idxTouched], with: .none)
})
I'm trying to figure out how to scroll all the way to the bottom of a UICollectionView when the screen first loads. I'm able to scroll to the bottom when the status bar is touched, but I'd like to be able to do that automatically when the view loads as well. The below works fine if I want to scroll to the bottom when the status bar is touched.
- (BOOL)scrollViewShouldScrollToTop:(UITableView *)tableView
{
NSLog(#"Detect status bar is touched.");
[self scrollToBottom];
return NO;
}
-(void)scrollToBottom
{//Scrolls to bottom of scroller
CGPoint bottomOffset = CGPointMake(0, collectionViewReload.contentSize.height - collectionViewReload.bounds.size.height);
[collectionViewReload setContentOffset:bottomOffset animated:NO];
}
I've tried calling [self scrollToBottom] in the viewDidLoad. This isn't working. Any ideas on how I can scroll to the bottom when the view loads?
I found that nothing would work in viewWillAppear. I can only get it to work in viewDidLayoutSubviews:
- (void)viewDidLayoutSubviews
{
[super viewDidLayoutSubviews];
[self.collectionView scrollToItemAtIndexPath:[NSIndexPath indexPathForItem:endOfModel inSection:0] atScrollPosition:UICollectionViewScrollPositionNone animated:NO];
}
Just to elaborate on my comment.
viewDidLoad is called before elements are visual so certain UI elements cannot be manipulated very well. Things like moving buttons around work but dealing with subviews often does not (like scrolling a CollectionView).
Most of these actions will work best when called in viewWillAppear or viewDidAppear. Here is an except from the Apple docs that points out an important thing to do when overriding either of these methods:
You can override this method to perform additional tasks associated
with presenting the view. If you override this method, you must call
super at some point in your implementation.
The super call is generally called before custom implementations. (so the first line of code inside of the overridden methods).
So had a similar issue and here is another way to come at it without using scrollToItemAtIndexPath
This will scroll to the bottom only if the content is larger than the view frame.
It's probably better to use scrollToItemAtIndexPath but this is just another way to do it.
CGFloat collectionViewContentHeight = myCollectionView.contentSize.height;
CGFloat collectionViewFrameHeightAfterInserts = myCollectionView.frame.size.height - (myCollectionView.contentInset.top + myCollectionView.contentInset.bottom);
if(collectionViewContentHeight > collectionViewFrameHeightAfterInserts) {
[myCollectionView setContentOffset:CGPointMake(0, myCollectionView.contentSize.height - myCollectionView.frame.size.height) animated:NO];
}
Swift 3 example
let sectionNumber = 0
self.collectionView?.scrollToItem(at: //scroll collection view to indexpath
NSIndexPath.init(row:(self.collectionView?.numberOfItems(inSection: sectionNumber))!-1, //get last item of self collectionview (number of items -1)
section: sectionNumber) as IndexPath //scroll to bottom of current section
, at: UICollectionViewScrollPosition.bottom, //right, left, top, bottom, centeredHorizontally, centeredVertically
animated: true)
Get indexpath for last item. Then...
- (void)scrollToItemAtIndexPath:(NSIndexPath *)indexPath atScrollPosition:(UICollectionViewScrollPosition)scrollPosition animated:(BOOL)animated
For me, i found next solution:
call reloadData in CollectionView, and make dcg on main to scroll.
__weak typeof(self) wSelf = self;
[wSelf.cv reloadData];
dispatch_async(dispatch_get_main_queue(), ^{
NSLog(#"HeightGCD:%#", #(wSelf.cv.contentSize.height));
[wSelf.cv scrollToItemAtIndexPath:[NSIndexPath indexPathForItem:50 inSection:0] atScrollPosition:UICollectionViewScrollPositionBottom animated:YES];
});
none of these were working so well for me, I ended up with this which will work on any scroll view
extension UIScrollView {
func scrollToBottom(animated: Bool) {
let y = contentSize.height - 1
let rect = CGRect(x: 0, y: y + safeAreaInsets.bottom, width: 1, height: 1)
scrollRectToVisible(rect, animated: animated)
}
}
The issue is likely that even if your collection view is on screen, it might not have the actual contentSize.
If you scroll in viewDidAppear, you will have a contentSize, but your scollectionview will briefly show content before scrolling.
And the problem with viewDidLayoutSubviews is that it is called multiple times, so you then need to add an ugly boolean to limit scrolling.
The best solution i've found is to force layout in view will appear.
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// force layout before scrolling to most recent
collectionView.layoutIfNeeded()
// now you can scroll however you want
// e.g. scroll to the right
let offset = collectionView.contentSize.width - collectionView.bounds.size.width
collectionView.setContentOffSet(CGPoint(x: offset, y: 0), animated: animated)
}
Consider if you can use performBatchUpdates like this:
private func reloadAndScrollToItem(at index: Int, animated: Bool) {
collectionView.reloadData()
collectionView.performBatchUpdates({
self.collectionView.scrollToItem(at: IndexPath(item: index, section: 0),
at: .bottom,
animated: animated)
}, completion: nil)
}
If index is the index of the last item in the collection's view data source it'll scroll all the way to the bottom.