Related
Whenever I call
reloadRowsAtIndexPaths
My UITableView contentOffset is removed, is there a delegate method I can use to catch the table view updating and set the Offset again so that it remains in place and does not animate into view, or simply prevent it doing this?
I am setting the contentOffest in viewDidLoad:
self.tableView.contentOffset = CGPointMake(0, 43);
Here is an example usage:
[self.tableView reloadRowsAtIndexPaths:[NSArray arrayWithObjects:[params valueForKey:#"index"], nil] withRowAnimation:UITableViewRowAnimationFade];
Which removes the contentOffset and animates it into view, which I do not want.
More specifically this appears to occur when the row being reloaded is at indexPath.section 0 and indexPath.row 0, i.e. the top row.
More Information
I am calling reloadRowsAtIndexPaths after an asynchronous request to fetch an image from a server. It basically works like so:
cellForRowAtIndexPath is called which checks for the presence of the thumb file on the disk, if the file is not present a placeholder is loaded in it's place and an asynchronous request is started in a background thread to fetch the image.
When the image download has completed I call reloadRowsAtIndexPaths for the correct cell so that the correct image fades in in place of the placeholder image.
The amount of cells may be different as the request is called inside cellForRowAtIndexPath so that the images load in as the cells load
cellForRowAtIndexPath file check
paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
path = [[paths objectAtIndex:0] stringByAppendingPathComponent:[NSString stringWithFormat:#"%#_small.gif",[[listItems objectAtIndex:indexPath.row] valueForKey:#"slug"]]];
if([[NSFileManager defaultManager] fileExistsAtPath:path]){
listCell.imageView.image = [UIImage imageWithData:[NSData dataWithContentsOfFile:path]];
} else {
listCell.imageView.image = [UIImage imageNamed:#"small_placeholder.gif"];
NSMutableDictionary *params = [[NSMutableDictionary alloc] initWithObjectsAndKeys:[[[listItems objectAtIndex:indexPath.row] valueForKey:#"image"] valueForKey:#"small"],#"image",[NSString stringWithFormat:#"%#_small.gif",[[listItems objectAtIndex:indexPath.row] valueForKey:#"slug"]], #"name",indexPath,#"index",#"listFileComplete",#"notification",nil];
[NSThread detachNewThreadSelector:#selector(loadImages:) toTarget:self withObject:params];
[params release];
}
File donwloaded notification:
-(void)fileComplete:(NSNotification *)notification {
[self performSelectorOnMainThread:#selector(reloadMainThread:) withObject:[notification userInfo] waitUntilDone:NO];
}
Cell reload ( I have hardcoded sections due to a bug with strange section numbers being passed very rarely causing a crash:
-(void)reloadMainThread:(NSDictionary *)params {
NSIndexPath *index;
switch ([[params valueForKey:#"index"] section]) {
case 0:
index = [NSIndexPath indexPathForRow:[[params valueForKey:#"index"] row] inSection:0];
[self.tableView reloadRowsAtIndexPaths:[NSArray arrayWithObjects:[params valueForKey:#"index"], nil] withRowAnimation:UITableViewRowAnimationNone];
break;
default:
index = [NSIndexPath indexPathForRow:[[params valueForKey:#"index"] row] inSection:2];
[self.tableView reloadRowsAtIndexPaths:[NSArray arrayWithObjects:index,nil] withRowAnimation:UITableViewRowAnimationFade];
break;
}
}
EDIT: My original answer may not have focused on the core problem
Are you changing your number of rows before the call to reloadRows...? reloadRows... is specifically to animate a value change, so your code should look something like this:
UICell *cell = [self cellForRowAtIndexPath:indexPath];
cell.label.text = #"Something new";
[self.tableView reloadRowsAtIndexPaths:[NSArray arrayWithObject:indexPath] withRowAnimation:UITableViewRowAnimationFade]];
Is that more or less what you look like, but the tableview is forgetting where it is?
Previous discussion
You don't call -beginUpdates and -endUpdates around a reload. You call -beginUpdates and -endUpdates around your related modifications of the backing data, during which you should be calling -insertRowsAtIndexPaths:withRowAnimation: and its relatives. If you call the updating routines, then you don't need to call the reload routines. Reloading in the middle of an update is undefined behavior.
See Batch Insertion, Deletion, and Reloading of Rows and Sections for details on when you use -beginUpdates.
Sounds like you are running into this problem due to incorrectly estimated row heights. Because (for some mysterious reason) the table view determines the new offset after reloading some cells using the estimated row height you want to make sure the tableView:estimatedHeightForRowAtIndexPath returns correct data for cells that have already been rendered. To accomplish this you could cache the seen row heights in a dictionary:
override func tableView(tableView: UITableView, willDisplayCell cell: UITableViewCell, forRowAtIndexPath indexPath: NSIndexPath) {
heightForIndexPath[indexPath] = cell.frame.height
}
then use this correct data or your estimate for not already loaded cells:
override func tableView(tableView: UITableView, estimatedHeightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
return heightForIndexPath[indexPath] ?? averageRowHeight
}
(HUGE thanks to eyuelt for the insight that estimated row height is used to determine the new offset.)
Have you tried the following:
CGPoint offset = [self.tableView contentOffset];
CGSize size = [self.tableView contentSize];
CGFloat percentScrolled = offset.y / size.height;
// call reloadRowsAtIndexPaths, inserts, deletes etc.
// ...
CGSize newSize = [self.tableView contentSize];
CGSize newOffset = CGPointMake(0, newSize.height * percentScrolled);
[self.tableView setContentOffset:newOffset animated:NO];
This seems a little too trivial, not sure it would work, but worth a shot.
Replace
[_tableView reloadData];
with
[_tableView reloadRowsAtIndexPaths:...withRowAnimation:...];
and it will be good.
I use this:
-(void)keepTableviewContentOffSetCell:(cellClass *)cell{
CGRect rect = cell.frame;
self.tableView.estimatedRowHeight = rect.size.height ;
}
You can use it before you reload the cell. The table view won't scroll when reload cell.
I have a list of data that I'm pulling from a web service. I refresh the data and I want to insert the data in the table view above the current data, but I want to keep my current scroll position in the tableview.
Right now I accomplish this by inserting a section above my current section, but it actually inserts, scrolls up, and then I have to manually scroll down. I tried disabling scrolling on the table before this, but that didn't work either.
This looks choppy and seems hacky. What is a better way to do this?
[tableView beginUpdates];
[tableView insertSections:[NSIndexSet indexSetWithIndex:0] withRowAnimation:UITableViewRowAnimationNone];
[tableView endUpdates];
NSUInteger iContentOffset = 200; //height of inserted rows
[tableView setContentOffset:CGPointMake(0, iContentOffset)];
If I understand your mission correctly,
I did it in this way:
if(self.tableView.contentOffset.y > ONE_OR_X_ROWS_HEIGHT_YOUDECIDE
{
self.delayOffset = self.tableView.contentOffset;
self.delayOffset = CGPointMake(self.delayOffset.x, self.delayOffset.y+ insertedRowCount * ONE_ROW_HEIGHT);
[self.tableView reloadData];
[self.tableView setContentOffset:self.delayOffset animated:NO];
}else
{
[self.tableView insertRowsAtIndexPath:indexPathArray WithRowAnimation:UITableViewRowAnimationTop];
}
With this code, If user is in the middle of the table and not the top, the uitableview will reload the new rows without animation and no scrolling.
If user is on the top of the table, he will see row insert animation.
Just pay attention in the code, I'm assuming the row's height are equal, if not , just calculate the height of all the new rows you are going to insert.
Hope that helps.
The best way I found to get my desired behavior is to not animate the insertion at all. The animations were causing the choppyness.
Instead I am calling:
[tableView reloadData];
// set the content offset to the height of inserted rows
// (2 rows * 44 points = 88 in this example)
[tableView setContentOffset:CGPointMake(0, 88)];
This makes the reload appear at the same time as the content offset change.
For further spectators looking for a Swift 3+ solution:
You need to save the current offset of the UITableView, then reload and then set the offset back on the UITableView.
func reloadTableView(_ tableView: UITableView) {
let contentOffset = tableView.contentOffset
tableView.reloadData()
tableView.layoutIfNeeded()
tableView.setContentOffset(contentOffset, animated: false)
}
Called by: reloadTableView(self.tableView)
Just call setContentOffset before endUpdates, that works for me.
[tableView setContentOffset:CGPointMake(0, iContentOffset)];
[tableView endUpdates];
I am using self sizing cells and the estimated row height was pretty useless because the cells can vary significantly in size. So calculating the contentOffset wasn't working for me.
The solution that I ended up with was quite simple and works perfectly.
So first up I should mention that I have some helper methods that allow me to get the data element for an index path, and the opposite - the index path for a data element.
-(void) loadMoreElements:(UIRefreshControl *) refreshControl {
NSIndexPath *topIndexPath = [NSIndexPath indexPathForRow:0 inSection:0]
id topElement = [myModel elementAtIndexPath:topIndexPath];
// Somewhere here you'll need to tell your model to get more data
[self.tableView reloadData];
NSIndexPath *indexPath = [myModel indexPathForElement:topElement];
[self.tableView scrollToRowAtIndexPath:indexPath
atScrollPosition:UITableViewScrollPositionTop
animated:NO];
[refreshControl endRefreshing];
}
None of the answers here really worked for me, so I came up with my solution. The idea is that when you pull down to refresh a table view (or load it asynchronously with new data) the new cells should silently come and sit on top of the tableview without disturbing the user's current offset. So here goes a solution that works (pretty much, with a caveat)
var indexpathsToReload: [IndexPath] = [] //this should contain the new indexPaths
var height: CGFloat = 0.0
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 1) {
UIView.performWithoutAnimation {
self.tableview.reloadSections(NSIndexSet(index: 0) as IndexSet, with: .none)
self.tableview.layoutIfNeeded()
indexpathsToReload.forEach({ (idx) in
height += self.feed.rectForRow(at: idx).height
})
let afterContentOffset = self.tableview.contentOffset
let newContentOffset = CGPoint(x: afterContentOffset.x, y: afterContentOffset.y + height)
self.tableview.setContentOffset(newContentOffset, animated: false)
}
}
CAVEAT (WARNING)
This technique will not work if your tableview is not "full" i.e. it only has a couple of cells in it. In that case you would need to also increase the contentSize of the tableview along with the contentOffset. I will update this answer once I figure that one out.
EXPLANATION:
Basically, we need to set the contentOffset of the tableView to a position where it was before the reload. To do this we simply calculate the total height of all the new cells that were added using a pre populated indexPath array (can be prepared when you obtain the new data and add them to the datasource), these are the indexPaths for the new cells. We then use the total height of all these new cell using rectForRow(at: indexPath), and set that as the y position of the contentOffset of the tableView after the reload. The DispatchQueue.main.asyncAfter is not necessary but I put it there because I just need to give the tableview some time to bounce back to it's original position since I am doing it on a "pull to refresh" way. Also note that in my case the afterContentOffset.y value is always 0 so I could have hard coded 0 there instead.
I am trying to get a UITableview to go to the top of the page when I reload the table data when I call the following from
- (void)pickerView:(UIPickerView *)pickerView didSelectRow:(NSInteger)row inComponent:(NSInteger)component {
// A bunch of code...
[TableView reloadData];
}
Along those same lines, I would also like to be able to go to a specific section when I reload the table data.
I tried placing the following, which seems applicable only to the row, before and after the reload, but nothing happens:
[TableView scrollToRowAtIndexPath:0 atScrollPosition:UITableViewScrollPositionTop animated:YES];
Here's another way you could do it:
Objective-C
[tableView setContentOffset:CGPointZero animated:YES];
Swift 3 and higher
tableView.setContentOffset(.zero, animated: true)
Probably the easiest and most straight forward way to do it.
After reading all of the answers here and elsewhere, I finally found a solution that works reliably and does not show the scrolling after the data is loaded. My answer is based on this post and my observation that you need a negative offset if you are using content insets:
func reloadAndScrollToTop() {
tableView.reloadData()
tableView.layoutIfNeeded()
tableView.contentOffset = CGPoint(x: 0, y: -tableView.contentInset.top)
}
To scroll to a specific section you need to specify the index path to it:
NSIndexPath *topPath = [NSIndexPath indexPathForRow:0 inSection:0];
[TableView scrollToRowAtIndexPath:topPath
atScrollPosition:UITableViewScrollPositionTop
animated:YES];
If you are uncertain about the number of rows in your tableView. Use the following:
NSIndexPath *topPath = [NSIndexPath indexPathForRow:NSNotFound inSection:0];
[TableView scrollToRowAtIndexPath:topPath
atScrollPosition:UITableViewScrollPositionTop
animated:YES];
WITHOUT ANIMATION
I'm on iOS11, using a plain tableview style with sticky headers and somehow the only way I got this to work correctly to have the tableview really on top after a reload without any strange flickers/animation behaviours was to use these methods in this order where ALL of these are necessary:
[self.tableView setContentOffset:CGPointZero animated:NO];
[self.tableView reloadData];
[self.tableView layoutIfNeeded];
[self.tableView setContentOffset:CGPointZero animated:NO];
I'm serious that all of these are necessary, even though it seems the first line is not relevant at all. Oh yeah, also very important, do NOT use this:
self.tableView.contentOffset = CGPointZero;
You would think that it's the same as the "setContentOffset animated:FALSE" but apparently it's not! Apple treats this method differently and in my case this only worked when using the full method with animated:FALSE.
WITH ANIMATION
I also wanted to try this with a nice scrolling animation to the top. There the content offset methods seemed to still cause some strange animation behaviours. The only way I got this working with a nice animation after some trial and error were these methods in this exact order:
[self.tableView reloadData];
[self.tableView layoutIfNeeded];
[self.tableView scrollToRowAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:0] atScrollPosition:UITableViewScrollPositionTop animated:TRUE];
Warning, make sure you have at least 1 row before running the last line of code to avoid crashing :)
I hope this helps someone someday!
This is working for me.
tableView.scrollToRow(at: IndexPath.init(row: 0, section: 0), at: .top, animated: true)
UITableView is a subclass of UIScrollView, so you can also use:
[mainTableView scrollRectToVisible:CGRectMake(0, 0, 1, 1) animated:YES];
I was a bit confused by some of the answers I found to this question due to incomplete explanations. From what I've gathered, there's two routes to accomplish what OP requested and they are as follows:
If animation isn't a requirement, I'd suggest reseting scroll position before data loads using one of the offset methods. The reason you should run offset before reloadData is that reloadData runs asynchronously. If you run offset after reload, your scroll position will probably be a few rows down from the top.
tableView.setContentOffset(.zero, animated: false)
tableView.reloadData()
If animation is a requirement, position your scroll to the top row after you run reloadData using scrollToRow. The reason scrollToRow works here is that it positions you at the top of the table, versus the offset method that positions you relative to data that has already loaded.
tableView.reloadData()
tableView.scrollToRow(at: IndexPath(row: 0, section: 0), at: .top, animated: true)
In swift 3.1
DispatchQueue.main.async(execute: {
self.TableView.reloadData()
self.TableView.contentOffset = .zero
})
For those still finding solution to this, you can use the following extension of UITableView.
Including a check where first section has row(s) or not. It goes till the section with row(s) comes and then scrolls to that one.
extension UITableView {
func scrollToFirst() {
self.reloadData() // if you don't want to reload data, remove this one.
for i in 0..<self.numberOfSections {
if self.numberOfRows(inSection: i) != 0 {
self.scrollToRow(at: IndexPath(row: 0, section: i), at: .top, animated: true)
break
}
}
}
}
Hope that helped you.
If you scroll before reloading and the number of rows decreases, you can have some strange animating behavior. To ensure the scrolling happens after reload, use a method like this:
- (void)reloadTableViewAndScrollToTop:(BOOL)scrollToTop {
[self.tableView reloadData];
if (scrollToTop) {
[self.tableView setContentOffset:CGPointZero animated:YES];
}
}
You must scroll UITableView at the first row of your table view by using this line of code:
self.yourTableView.scrollToRow(at: IndexPath(row: 0, section: 0), at: .top, animated: true)
After considering the options discussed above my version of the solutions is the following. It works consistently in iOS 11 & 12 for both UICollectionView & UITableView.
UICollectionView:
DispatchQueue.main.async { [weak self] in
guard self?.<your model object>.first != nil else { return }
self?.collectionView.scrollToItem(at: IndexPath(row: 0, section: 0),
at: .top,
animated: false)
}
UITableView:
DispatchQueue.main.async { [weak self] in
guard self?.<your model object>.first != nil else { return }
self?.collectionView.scrollToRow(at: IndexPath(row: 0, section: 0),
at: .top,
animated: false)
}
Perfectly to the top of page on reload, by twice reloading
dataForSource = nil
tableView.reloadData()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
self.dataForSource = realData
self.tableView.reloadData()
}
Xcode 13.2.1 & Swift 5.6
tableView.reloadData()
tableView.layoutIfNeeded()
tableView.contentOffset = .zero
I have an UITableView with rows and sections.
I would like to scroll to the first item of the second section, letting the header of the first section visible. Like if I had manually scrolled the list until reaching that state.
---- TOP OF SCREEN ----
Header of first section
Header of the second section
cell 1
cell 2
cell 3
Header of the third section
cell 1
cell 2
...
scrollToRowAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:1]
does not do the job, it hides the header of the first section.
We're moving on. I found this method based on Kevin's idea. To be able to set animated to YES, I catch the end of animation using a delegate method of UIScrollView. It works. But any solution that would help not doing 2 animations would be greatly appreciated. Any idea about how to do this ?
- (IBAction) scrollToToday:(BOOL)animate {
[self.tableView scrollToRowAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:1] atScrollPosition:UITableViewScrollPositionTop animated:animate];
if (animate == NO) [self showFirstHeaderLine:NO];
}
- (void)scrollViewDidEndScrollingAnimation:(UIScrollView *)scrollView {
[self showFirstHeaderLine:YES];
}
- (void) showFirstHeaderLine:(BOOL)animate {
CGRect headerRect = [self.tableView rectForHeaderInSection:1];
CGPoint scrollPoint = headerRect.origin;
scrollPoint.y -= headerRect.size.height;
[self.tableView setContentOffset:scrollPoint animated:animate];
}
Dude to this code, the process when animated is set to YES should loop infinitely beetween scrollViewDidEndScrollingAnimation and showFirstHeaderLine... It loops, yes, but only once... Any idea about why ?
You can grab the rect for the row you want, then subtract the height of the header of the previous section and scroll to that point. Something like the following (untested) should work:
CGRect rowRect = [table rectForRowAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:1]];
CGRect headerRect = [table rectForHeaderInSection:0];
rowRect.origin.y -= headerRect.size.height;
rowRect.size.height += headerRect.size.height;
[table scrollRectToVisible:rowRect animated:YES]; // UITableView is a subclass of UIScrollView
I tried your code, and it works!!
For the loop question, since you are setting a offset(SetContentOffset), it has nothing to do with the scroll. It is will not call scrollView delegate. SO the scrollViewDidEndScrollingAnimation will be called only once, which has been called from scrollToRowAtIndexPath.
I have a UITableView that I am adding a row to with an animation (using insertRowsAtIndexPaths:withRowAnimation:). This is all good as long as the table is not longer than the screen.
If it is bigger than the screen then I am trying to scroll to the bottom, but it is not quite working how I want. If I scroll to the new row after it is added I miss the animation. If I try to scroll to that indexPath before it is added it throws an exception (about it not being a valid indexPath)
Is there a solution to this other than adding a blank row?
I had this same issue. Here was my solution.
First -
Update your data source
Second -
NSIndexPath *path = [NSIndexPath indexPathForRow:([arrayWhichFeeds count] - 1 inSection:0]];
NSArray *paths = [NSArray arrayWithObject:path];
[myTableView insertRowsAtIndexPaths:paths withRowAnimation:UITableViewRowAnimationTop];
[myTableView scrollToRowAtIndexPath:path atScrollPosition:UITableViewScrollPositionBottom animated:YES];
*Please note that this IS NOT wrapped in the [tableView beginUpdades] and [tableView endUpdates] code. If you do it will not work as desired.
Give it a try, it should animate in the new rows from the bottom while scrolling to them.
Yes there is a solution without adding a blank row.
Note: In the code, I consider that there is only 1 section but many rows. You can modify the code to manage multiple sections as well.
- (void)theMethodInWhichYouInsertARowInTheTableView
{
//Add the object in the array which feeds the tableview
NSString *newStringObject = #"New Object";
[arrayWhichFeeds addObject:newStringObject];
[myTableView beginUpdates];
NSArray *paths = [NSArray arrayWithObject:[NSIndexPath indexPathForRow:([arrayWhichFeeds count] - 1) inSection:0]];
[myTableView insertRowsAtIndexPaths:paths withRowAnimation:NO];
[myTableView endUpdates];
[myTableView reloadData];
[myTableView scrollToRowAtIndexPath:[NSIndexPath indexPathForRow:([arrayWhichFeeds count] - 1) inSection:0] atScrollPosition:UITableViewScrollPositionBottom animated:YES];
}
Other techniques, including those mentioned under this question did not work for me. This did however:
Add the new item to the internal dataSource collection.
Set a flag in the item that indicates it is "new". Force the display of the cell to show nothing when this flag is set.
Immediately call tableView:reloadData:
Now the new item is in this table but will visually appear empty (due to the flag).
Check to see if this new item is visible using tableView.indexPathsForVisibleRows.
If the item was is on screen immediately flip that "new" flag on the dataSource item to NO and call tableView:reloadRowsAtIndexPaths with an animation set. Now it will appear as if this item was just added. (you're done in this case)
If it wasn't on screen scroll it into view with tableView:scrollToRowAtIndexPath: but don't immediately call reloadRowsAtIndexPath...
Handle the message (void)scrollViewDidEndScrollingAnimation: and do that same reloadRowsAtIndexPath from step 6. I suspect this method is called anytime scrolling happens, so you'll have to detect when it's called from step 7 and when it's called because the user is scrolling around.
I worked this technique out (and wrote this answer) when I 1st started iOS development, but this did work out long term.
Remeber to do this in the main thread otherwise it won't scroll to the required position.
Update data source
Insert row at bottom
Scroll to bottom
Example:
YOUR_ARRAY.append("new string")
tableView.insertRows(at: [IndexPath(row: YOUR_ARRAY.count-1, section: 0)], with: .automatic)
tableView.scrollToRow(at: IndexPath(row: YOUR_ARRAY.count-1, section: 0), at: UITableViewScrollPosition.bottom, animated: true)
More generally, to scroll to the bottom:
NSIndexPath *scrollIndexPath = [NSIndexPath indexPathForRow:([self.table numberOfRowsInSection:([self.table numberOfSections] - 1)] - 1) inSection:([self.table numberOfSections] - 1)];
[self.table scrollToRowAtIndexPath:scrollIndexPath atScrollPosition:UITableViewScrollPositionBottom animated:YES];
This works for me. Instead of inserting a row I just highlight it (by fading it in out.) The principle is the same, though.
if let definiteIndexPath = indexPathDelegate.getIndexPath(toDoItem) {
UIView.animateWithDuration(0.5, delay: 0, options: UIViewAnimationOptions.CurveEaseInOut, animations: {
self.tableView.scrollToRowAtIndexPath(definiteIndexPath, atScrollPosition: .Middle, animated: false)
}, completion: {
(finished: Bool) -> Void in
// Once the scrolling is done fade the text out and back in.
UIView.animateWithDuration(5, delay: 1, options: .Repeat | .Autoreverse, animations: {
self.tableView.reloadRowsAtIndexPaths([definiteIndexPath], withRowAnimation: UITableViewRowAnimation.Fade)
}, completion: nil)
})
}