Keep uitableview static when inserting rows at the top

I have a tableView that I'm inserting rows into at the top.

Whilst I'm doing this I want the current view to stay completely still, so the rows only appear if you scroll back up.

I've tried saving the current position of the underlying UIScrollview and resetting the position after the rows have been inserted but this results in a judder, up and down, although it does end up back in the same place.

Is there a good way of achieving this ?

Update: I am using beginUpdate, then insertRowsAtIndexPath, endUpdates. There is no reloadData call.

scrollToRowAtIndexPath jumps to the top of the current cell (saved before adding rows).

The other approach I tried, which ends up in exactly the right pace, but with a judder is.

save tableView currentOffset. (Underlying scrollView method)
Add rows (beginUpdates,insert...,endUpdates) 
reloadData ( to force a recalulation of the scrollview size )
Recalculate the correct new offset from the bottom of the scrollview
setContentOffset (Underlying scrollview method)

Trouble is the reloadData causes the scrollview/tableview to start scrolling briefly, then the setContentOffset returns it to the correct place.

Is there a way of getting a tableView to work out it's new size without starting display ?

Wrapping the whole thing in a beginAnimation commitAnimation doesn't help much either.

Update 2: This can clearly be done - see the offical twitter app for one when you pull down for updates.

Answers


There's really no need to sum up all rows height, the new contentSize after reloading the table is already representing that. So all you have to do is calculate the delta of contentSize height and add it to the current offset.

    ...
    CGSize beforeContentSize = self.tableView.contentSize;
    [self.tableView reloadData];
    CGSize afterContentSize = self.tableView.contentSize;

    CGPoint afterContentOffset = self.tableView.contentOffset;
    CGPoint newContentOffset = CGPointMake(afterContentOffset.x, afterContentOffset.y + afterContentSize.height - beforeContentSize.height);
    self.tableView.contentOffset = newContentOffset;
    ...

-(void) updateTableWithNewRowCount : (int) rowCount
{    
    //Save the tableview content offset 
    CGPoint tableViewOffset = [self.tableView contentOffset];                                                                                                            

    //Turn of animations for the update block 
    //to get the effect of adding rows on top of TableView
    [UIView setAnimationsEnabled:NO];

    [self.tableView beginUpdates];                    

    NSMutableArray *rowsInsertIndexPath = [[NSMutableArray alloc] init];        

    int heightForNewRows = 0;

    for (NSInteger i = 0; i < rowCount; i++) {

        NSIndexPath *tempIndexPath = [NSIndexPath indexPathForRow:i inSection:SECTION_TO_INSERT];
        [rowsInsertIndexPath addObject:tempIndexPath];

        heightForNewRows = heightForNewRows + [self heightForCellAtIndexPath:tempIndexPath];                        
    }

    [self.tableView insertRowsAtIndexPaths:rowsInsertIndexPath withRowAnimation:UITableViewRowAnimationNone];                                                                            

    tableViewOffset.y += heightForNewRows;                                                                                 

    [self.tableView endUpdates]; 

    [UIView setAnimationsEnabled:YES];

    [self.tableView setContentOffset:tableViewOffset animated:NO];           
}


-(int) heightForCellAtIndexPath: (NSIndexPath *) indexPath
{

    UITableViewCell *cell =  [self.tableView cellForRowAtIndexPath:indexPath];

    int cellHeight   =  cell.frame.size.height;

    return cellHeight;
}

Simply pass in the row count of the new rows to insert at the top.


@Dean's way of using an image cache is too hacky and I think it destroys the responsiveness of the UI.

One proper way: Use a UITableView subclass and override -setContentSize: in which you can by some means calculate how much the table view is pushed down and offset that by setting contentOffset.

This is a simplest sample code to handle the simplest situation where all insertions happen at the top of table view:

@implementation MyTableView

- (void)setContentSize:(CGSize)contentSize {
        // I don't want move the table view during its initial loading of content.
    if (!CGSizeEqualToSize(self.contentSize, CGSizeZero)) {
        if (contentSize.height > self.contentSize.height) {
            CGPoint offset = self.contentOffset;
            offset.y += (contentSize.height - self.contentSize.height);
            self.contentOffset = offset;
        }
    }
    [super setContentSize:contentSize];
}

@end

had the same problem and found a solution.

    save tableView currentOffset. (Underlying scrollView method)
    //Add rows (beginUpdates,insert...,endUpdates) // don't do this!
    reloadData ( to force a recalulation of the scrollview size )
    add newly inserted row heights to contentOffset.y here, using tableView:heightForRowAtIndexPath:
    setContentOffset (Underlying scrollview method)

like this:

- (CGFloat) firstRowHeight
{
    return [self tableView:[self tableView] heightForRowAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:0]];
}

...
CGPoint offset = [[self tableView] contentOffset];
[self tableView] reloadData];
offset.y += [self firstRowHeight];
if (offset.y > [[self tableView] contentSize].height) {
    offset.y = 0;
}
[[self tableView] setContentOffset:offset];
...

works perfectly, without glitches.


I did some testing with a core data sample project and got it to sit still while new cells were added above the top visible cell. This code would need adjustment for tables with empty space on the screen, but once the screen is filled, it works fine.

static CGPoint  delayOffset = {0.0};

- (void)controllerWillChangeContent:(NSFetchedResultsController*)controller {
    if ( animateChanges )
        [self.tableView beginUpdates];
    delayOffset = self.tableView.contentOffset; // get the current scroll setting
}

Added this at cell insertion points. You may make counterpart subtraction for cell deletion.

    case NSFetchedResultsChangeInsert:

        delayOffset.y += self.tableView.rowHeight;  // add for each new row
        if ( animateChanges )   
            [tableView insertRowsAtIndexPaths:[NSArray arrayWithObject:newIndexPath] withRowAnimation:UITableViewRowAnimationNone];
        break;

and finally

- (void)controllerDidChangeContent:(NSFetchedResultsController *)controller {
    if ( animateChanges )   
    {
        [self.tableView setContentOffset:delayOffset animated:YES];
        [self.tableView endUpdates];
    }
    else
    {
        [self.tableView reloadData];
        [self.tableView setContentOffset:delayOffset animated:NO];
    }
}

With animateChanges = NO, I could not see anything move when cells were added.

In testing with animateChanges = YES, the "judder" was there. It seems the animation of cell insertion did not have the same speed as the animated table scrolling. While the result at the end could end with visible cells exactly where they started, the whole table appears to move 2 or 3 pixels, then move back.

If the animation speeds could be make to equal, it may appear to stay put.

However, when I pressed the button to add rows before the previous animation finished, it would abruptly stop the animation and start the next, making an abrupt change of position.


@Dean,

You can change your code like this to prevent animating.

[tableView beginUpdates];
[UIView setAnimationsEnabled:NO];

// ...

[tableView endUpdates];

[tableView setContentOffset:newOffset animated:NO];
[UIView setAnimationsEnabled:YES];

Everyone loves copy and pasting code examples, so here's an implementation of Andrey Z.'s answer.

This is in my delegateDidFinishUpdating:(MyDataSourceDelegate*)delegate method

if (self.contentOffset.y <= 0)
{
    [self beginUpdates];
    [self insertRowsAtIndexPaths:insertedIndexPaths withRowAnimation:insertAnimation];
    [self endUpdates];
}
else
{
    CGPoint newContentOffset = self.contentOffset;
    [self reloadData];

    for (NSIndexPath *indexPath in insertedIndexPaths)
        newContentOffset.y += [self.delegate tableView:self heightForRowAtIndexPath:indexPath];

    [self setContentOffset:newContentOffset];

    NSLog(@"New data at top of table view");
}

The NSLog at the bottom can be replaced with a call to show a view that indicated there's fresh data.


I faced situation where there are many sections which may have different row count between -reloadData calls because of custom grouping, and row heights vary. So here is solution based on AndreyZ's. It contentHeight property of UIScrollView before and after -reloadData and it seems like more universal.

CGFloat contentHeight = self.tableView.contentSize.height;
CGPoint offset = self.tableView.contentOffset;
[self.tableView reloadData];

offset.y += (self.tableView.contentSize.height - contentHeight);
if (offset.y > [self.tableView contentSize].height)
    offset.y = 0;

[self.tableView setContentOffset:offset];

I want add additional condition. If your code in iOS11 or more, you need do like below;

In iOS 11, table views use estimated heights by default. This means that the contentSize is just as estimated value initially. If you need to use the contentSize, you’ll want to disable estimated heights by setting the 3 estimated height properties to zero:

tableView.estimatedRowHeight = 0 tableView.estimatedSectionHeaderHeight = 0 tableView.estimatedSectionFooterHeight = 0


How are you adding the rows to the table?

If you're changing the data source and then calling reloadData, that may result in the table being scrolled to the top again.

However, if you use the beginUpdates, insertRowsAtIndexPaths:withRowAnimation:, endUpdates methods, you should be able to insert rows without having to call reloadData thus keeping the table in its original position.

Don't forget to modify your data source before calling endUpdates or else you'll end up with an internal inconsistency exception.


You don't need to do so much difficult operations, furthermore these manipulations wouldn't work perfectly. The simple solution is to rotate table view, and then rotate cells into it.

tableView.transform = CGAffineTransformMakeRotation(M_PI);

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

   cell.transform = CGAffineTransformMakeRotation(M_PI);

}

Use [tableView setScrollIndicatorInsets:UIEdgeInsetsMake(0, 0, 0, 310)] to set relative position to scroll indicator. It will be on the right side after you table view rotation.


Just a heads up it does not seem possible to do this if you return estimated heights for the tableview.

   - (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath ;

If you implement this method and return a rough height your tableview will jump about when reloading as it appears to use these heights when setting the offsets.

To get it working use one of the above answers (I went with @Mayank Yadav answer), don't implement the estimatedHeight method and cache the cell heights (remembering to adjust the cache when you insert additional cells at the top).


Simple solution to disable animations

func addNewRows(indexPaths: [NSIndexPath]) {
    let addBlock = { () -> Void in
        self.tableView.beginUpdates()
        self.tableView.insertRowsAtIndexPaths(indexPaths, withRowAnimation: .None)
        self.tableView.endUpdates()
    }

    tableView.contentOffset.y >= tableView.height() ? UIView.performWithoutAnimation(addBlock) : addBlock()
}

I solved this in the end by rendering the current tableview into a UIImage and then putting a temporary UIImageView over the tableview whilst it animates.

The following code will generate the image

// Save the current tableView as an UIImage
CSize pageSize = [[self tableView] frame].size;
UIGraphicsBeginImageContextWithOptions(pageSize, YES, 0.0); // 0.0 means scale appropriate for device ( retina or no )
CGContextRef resizedContext = UIGraphicsGetCurrentContext();
CGPoint offset = [[self tableView] contentOffset];
CGContextTranslateCTM(resizedContext,-(offset.x),-(offset.y));

[[[self tableView  ]layer] renderInContext:resizedContext];
UIImage *viewImage = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();    

You need to keep track of how much the tableview will have grown by whilst inserting rows and make sure you scroll the tableview back to the exact same position.


Based on Andrey Z's answer, here is a live example working perfect for me...

int numberOfRowsBeforeUpdate = [controller.tableView numberOfRowsInSection:0];
CGPoint currentOffset = controller.tableView.contentOffset;

if(numberOfRowsBeforeUpdate>0)
{
    [controller.tableView reloadData];
    int numberOfRowsAfterUpdate = [controller.tableView numberOfRowsInSection:0];

    float rowHeight = [controller getTableViewCellHeight];  //custom method in my controller
    float offset = (numberOfRowsAfterUpdate-numberOfRowsBeforeUpdate)*rowHeight;

    if(offset>0)
    {
        currentOffset.y = currentOffset.y+offset;
        [controller.tableView setContentOffset:currentOffset];
    }
}
else
    [controller.tableView reloadData];

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
}

AmitP answers, Swift 3 version

let beforeContentSize = self.tableView.contentSize
self.tableView.reloadData()
let afterContentSize = self.tableView.contentSize
let afterContentOffset = self.tableView.contentOffset
let newContentOffset = CGPoint(x: afterContentOffset.x, y: afterContentOffset.y + afterContentSize.height - beforeContentSize.height)
                    self.tableView.contentOffset = newContentOffset

How about using scrollToRowAtIndexPath:atScrollPosition:animated:? You should be able to just add an element to your data source, set the row with the above mentioned method and reload the table...


Need Your Help

Fancybox slides going to bottom of page in IE8, under background

internet-explorer-8 fancybox z-index

I'm using Fancybox v.2 (with jquery 1.7.1) - it works beautifully in chrome, but in IE8 the whole thing appears at the bottom of the page, with the greyed background on top.

different format / efficiency for MySQL statement

mysql performance join subquery

This is a question of different way to run this statement or a way to make it more efficient really.