47

By default Collection View maintains content offset while inserting cells. On the other hand I'd like to insert cells above the currently displaying ones so that they appear above the screen top edge like Messages.app do when you load earlier messages. Does anyone know the way to achieve it?

marvin_yorke
  • 3,469
  • 4
  • 25
  • 35

20 Answers20

65

This is the technique I use. I've found others cause strange side effects such as screen flicker:

    CGFloat bottomOffset = self.collectionView.contentSize.height - self.collectionView.contentOffset.y;

    [CATransaction begin];
    [CATransaction setDisableActions:YES];

    [self.collectionView performBatchUpdates:^{
        [self.collectionView insertItemsAtIndexPaths:indexPaths];
    } completion:^(BOOL finished) {
        self.collectionView.contentOffset = CGPointMake(0, self.collectionView.contentSize.height - bottomOffset);
    }];

    [CATransaction commit];
ManicJason
  • 545
  • 5
  • 13
James Martin
  • 1,050
  • 7
  • 8
  • Works great, thanks! Please see below the version in Swift 2. – Sebastian Sep 21 '15 at 09:31
  • Your solution does not work (unfortunately) if I do `[self.tableView reloadItemsAtIndexPaths:indexPathsToUpdate];` with changes in the layout. – Colas Oct 20 '15 at 15:31
  • 2
    Works great just a very slight flicker on update, not bad though. Best I have found aside from flipping whole thing upside down. When I did the flip though it made my collection view very laggy because it has a lot of stuff in it. So for me this was best. – Wayne Filkins Oct 17 '16 at 22:54
  • what is indexpaths ? – Shikha Sharma Apr 07 '17 at 13:47
  • To remove the flicker, you can try method of @Peter Stajger – shellhue Jul 20 '17 at 09:50
27

James Martin’s fantastic version converted to Swift 2:

let amount = 5 // change this to the amount of items to add
let section = 0 // change this to your needs, too
let contentHeight = self.collectionView!.contentSize.height
let offsetY = self.collectionView!.contentOffset.y
let bottomOffset = contentHeight - offsetY

CATransaction.begin()
CATransaction.setDisableActions(true)

self.collectionView!.performBatchUpdates({
    var indexPaths = [NSIndexPath]()
    for i in 0..<amount {
        let index = 0 + i
        indexPaths.append(NSIndexPath(forItem: index, inSection: section))
    }
    if indexPaths.count > 0 {
        self.collectionView!.insertItemsAtIndexPaths(indexPaths)
    }
    }, completion: {
        finished in
        print("completed loading of new stuff, animating")
        self.collectionView!.contentOffset = CGPointMake(0, self.collectionView!.contentSize.height - bottomOffset)
        CATransaction.commit()
})
Sebastian
  • 8,952
  • 3
  • 32
  • 30
26

My approach leverages subclassed flow layout. This means that you don't have to hack scrolling/layout code in a view controller. Idea is that whenever you know that you are inserting cells on top you set custom property you flag that next layout update will be inserting cells to top and you remember content size before update. Then you override prepareLayout() and set desired content offset there. It looks something like this:

define variables

private var isInsertingCellsToTop: Bool = false
private var contentSizeWhenInsertingToTop: CGSize?

override prepareLayout() and after calling super

if isInsertingCellsToTop == true {
    if let collectionView = collectionView, oldContentSize = contentSizeWhenInsertingToTop {
        let newContentSize = collectionViewContentSize()
        let contentOffsetY = collectionView.contentOffset.y + (newContentSize.height - oldContentSize.height)
        let newOffset = CGPointMake(collectionView.contentOffset.x, contentOffsetY)
        collectionView.setContentOffset(newOffset, animated: false)
}
    contentSizeWhenInsertingToTop = nil
    isInsertingMessagesToTop = false
}
Tankista
  • 1,685
  • 17
  • 26
  • 3
    This is probably the best option so far. Looks very promising – marvin_yorke Dec 24 '15 at 19:21
  • 1
    Works for me only when inserting the new section using UIView.performWithoutAnimation({self.collectionView?.insertSections(NSIndexSet(index: 0))}) but scrolling is suddenly stopped. Is there a way to achieve continuous scrolling i.e. "silently" inserting sections above the CollectionView's bounds? – RTasche Jan 05 '16 at 19:21
  • @hacker2007 try using collectionView.contentOffset = newOffset instead of the setContentOffset This worked for me. – Jon Andersen Jan 20 '16 at 00:21
  • 1
    Great approach. There are a few minor issues with your code as of May 30 2017 - i've posted a Swift 3 version of your answer below. Thanks Peter! – xaphod May 30 '17 at 20:42
  • This is by far the cleaner and concise solution i have found about this. Thanks for sharing. – Javier Cadiz Oct 24 '17 at 12:32
  • can someone give an example of how to use this – Lance Samaria Nov 29 '20 at 15:53
15

I did this in two lines of code (although it was on a UITableView) but I think you'd be able to do it the same way.

I rotated the tableview 180 degrees.

Then I rotated each tableview cell by 180 degrees also.

This meant that I could treat it as a standard top to bottom table but the bottom was treated like the top.

Fogmeister
  • 76,236
  • 42
  • 207
  • 306
  • 5
    Better scale transform it by (1,-1) – k06a Oct 21 '14 at 15:43
  • @k06a care to explain why? You would still have to scale the cells too by (1, -1) too. Isn't it the same? – Fogmeister Oct 21 '14 at 15:47
  • 3
    @Fogmeister its better to scale the table (1,-1) and cells (1,-1). If you rotate, you also flip the scroll indicator to the other side of the screen. Reflecting on the x axis maintains the scroll indicator. – Andy Poes Oct 27 '16 at 18:14
  • @AndyPoes ah nice. In my app I didn't need the scroll indicator but yeah. That definitely makes sense :-) – Fogmeister Oct 27 '16 at 18:21
  • @k06a awesome. didn't notice that until I read your comment. double thumbs up – 0xKayvan Jan 31 '17 at 09:24
8

Swift 3 version code: based on James Martin answer

    let amount = 1 // change this to the amount of items to add
    let section = 0 // change this to your needs, too
    let contentHeight = self.collectionView.contentSize.height
    let offsetY = self.collectionView.contentOffset.y
    let bottomOffset = contentHeight - offsetY
                    
    CATransaction.begin()
    CATransaction.setDisableActions(true)
                    
    self.collectionView.performBatchUpdates({
      var indexPaths = [NSIndexPath]()
      for index in 0..<amount {
        indexPaths.append(NSIndexPath(item: index, section: section))
      }
      if indexPaths.count > 0 {
        self.collectionView.insertItems(at: indexPaths as [IndexPath])
      }
    }, completion: {
       finished in
       print("completed loading of new stuff, animating")
       self.collectionView.contentOffset = CGPoint(x: 0, y: self.collectionView.contentSize.height - bottomOffset)
       CATransaction.commit()
    })
mriaz0011
  • 1,887
  • 23
  • 11
7

Here's a slightly tweaked version of Peter's solution (subclassing flow layout, no upside-down, lightweight approach). It's Swift 3. Note UIView.animate with zero duration - that's to allow the animation of the even/oddness of the cells (what's on a row) animate, but stop the animation of the viewport offset changing (which would look terrible)

Usage:

        let layout = self.collectionview.collectionViewLayout as! ContentSizePreservingFlowLayout
        layout.isInsertingCellsToTop = true
        self.collectionview.performBatchUpdates({
            if let deletionIndexPaths = deletionIndexPaths, deletionIndexPaths.count > 0 {
                self.collectionview.deleteItems(at: deletionIndexPaths.map { return IndexPath.init(item: $0.item+twitterItems, section: 0) })
            }
            if let insertionIndexPaths = insertionIndexPaths, insertionIndexPaths.count > 0 {
                self.collectionview.insertItems(at: insertionIndexPaths.map { return IndexPath.init(item: $0.item+twitterItems, section: 0) })
            }
        }) { (finished) in
            completionBlock?()
        }

Here's ContentSizePreservingFlowLayout in its entirety:

    class ContentSizePreservingFlowLayout: UICollectionViewFlowLayout {
        var isInsertingCellsToTop: Bool = false {
            didSet {
                if isInsertingCellsToTop {
                    contentSizeBeforeInsertingToTop = collectionViewContentSize
                }
            }
        }
        private var contentSizeBeforeInsertingToTop: CGSize?

        override func prepare() {
            super.prepare()
            if isInsertingCellsToTop == true {
                if let collectionView = collectionView, let oldContentSize = contentSizeBeforeInsertingToTop {
                    UIView.animate(withDuration: 0, animations: {
                        let newContentSize = self.collectionViewContentSize
                        let contentOffsetY = collectionView.contentOffset.y + (newContentSize.height - oldContentSize.height)
                        let newOffset = CGPoint(x: collectionView.contentOffset.x, y: contentOffsetY)
                        collectionView.contentOffset = newOffset
                    })
                }
                contentSizeBeforeInsertingToTop = nil
                isInsertingCellsToTop = false
            }
        }
    }
xaphod
  • 6,392
  • 2
  • 37
  • 45
  • 1
    This worked for me :) Just a couple of things to mention: "if isInsertingCellsToTop == true" can simple read "if isInsertingCellsToTop" 'cause it's a Bool and I replaced "UIView.animate(withDuration: 0, animations:" with UIView.performWithoutAnimation. – Neil Billingham Sep 28 '17 at 08:48
6

Adding to Fogmeister's answer (with code), the cleanest approach is to invert (turn upside-down) the UICollectionView so that you have a scroll view that is sticky to the bottom rather than the top. This also works for UITableView, as Fogmeister points out.

- (void)viewDidLoad
{
    [super viewDidLoad];

    self.collectionView.transform = CGAffineTransformMake(1, 0, 0, -1, 0, 0);

}

In Swift:

override func viewDidLoad() {
    super.viewDidLoad()

    collectionView.transform = CGAffineTransformMake(1, 0, 0, -1, 0, 0)
}

This has the side effect of also displaying your cells upside-down so you have to flip those as well. So we transfer the trasform (cell.transform = collectionView.transform) like so:

- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath
{
    UICollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"Cell" forIndexPath:indexPath];

    cell.transform = collectionView.transform;

    return cell;
}

In Swift:

func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
    var cell = collectionView.dequeueReusableCellWithReuseIdentifier("Cell", forIndexPath: indexPath) as! UICollectionViewCell

    cell.transform = collectionView.transform

    return cell
}

Lastly, the main thing to remember when developing under this design is that the NSIndexPath parameters in delegates are reversed. So indexPath.row == 0 is the row at on the bottom of the collectionView where it is normally at the top.

This technique is used in many open source projects to produce the behavior described including the popular SlackTextViewController (https://github.com/slackhq/SlackTextViewController) maintained by Slack

Thought I would add some code context to Fogmeister's fantastic answer!

Rishil Patel
  • 1,977
  • 3
  • 14
  • 30
mattr
  • 5,458
  • 2
  • 27
  • 22
  • 1
    This approach works very well for static content, but when calling self.collectionView.insertItemsAtIndexPaths(indexPaths) the collectionView performs some animations which cause the cells to be flipped to their original direction. Did you ever run into this and managed to solve it? – Jon Andersen Jan 14 '16 at 16:10
  • @JonAndersen I havent experienced this. you have control over these animations: (https://www.objc.io/issues/12-animations/collectionview-animations/). are u using a custom one? if not do you need the animation? you can try removing or changing the animation to one that doesnt effect the transform (ie fade in). – mattr Jan 14 '16 at 16:20
  • I tried with with a custom layout without any animations, however it still has some animations that I'm not able to find and stop. Thanks – Jon Andersen Jan 25 '16 at 21:55
  • @JonAndersen did you ever figure this out? I'm having the same issue you described when using insertItemsAtIndexPaths and can't fix it. Tried creating a subclass of UICollectionViewFlowLayout and overriding the initialLayoutAttributesForAppearing with the same transform as collection view.. didn't do a thing. Thanks – Walker Apr 01 '17 at 17:09
  • @Walker I don't think I was satisfied enough with the solution. I started using Async Display Kit, and that solved all my issues with rendering. – Jon Andersen Apr 03 '17 at 17:26
  • @JonAndersen do you mine posting some code or pointing me in the right direction? I really appreciate it man – Walker Apr 03 '17 at 17:27
  • @Walker I don't have any code right now. But AsyncDisplayKit has a propety on UITableView & UICollectionView which works perfectly. http://asyncdisplaykit.org/docs/inversion.html – Jon Andersen Apr 10 '17 at 14:28
  • 1
    Setting `UICollectionViewCell.transform` didn't work for me as animations (`animateWithDuration`/`performBatchUpdates`) trampled the transform. Instead, setting `UICollectionViewCell.contentView.transform` worked better. – jedwidz Oct 23 '18 at 06:27
  • A side-effect of this technique is that tapping on the status bar scrolls to the bottom of the view rather than the top. (See docs for property `scrollsToTop` to disable or customize this.) – jedwidz Jan 03 '19 at 09:07
4

This is what I learned from JSQMessagesViewController: How maintain scroll position?. Very simple, useful and NO flicker!

 // Update collectionView dataSource
data.insert(contentsOf: array, at: startRow)

// Reserve old Offset
let oldOffset = self.collectionView.contentSize.height - self.collectionView.contentOffset.y

// Update collectionView
collectionView.reloadData()
collectionView.layoutIfNeeded()

// Restore old Offset
collectionView.contentOffset = CGPoint(x: 0, y: self.collectionView.contentSize.height - oldOffset)
Steven
  • 99
  • 5
3

Love James Martin’s solution. But for me it started to breakdown when inserting/deleting above/below a specific content window. I took a stab at subclassing UICollectionViewFlowLayout to get the behavior I wanted. Hope this helps someone. Any feedback appreciated :)

@interface FixedScrollCollectionViewFlowLayout () {

    __block float bottomMostVisibleCell;
    __block float topMostVisibleCell;
}

@property (nonatomic, assign) BOOL isInsertingCellsToTop;
@property (nonatomic, strong) NSArray *visableAttributes;
@property (nonatomic, assign) float offset;;

@end

@implementation FixedScrollCollectionViewFlowLayout


- (id)initWithCoder:(NSCoder *)aDecoder {

    self = [super initWithCoder:aDecoder];

    if (self) {
        _isInsertingCellsToTop = NO;
    }
    return self;
}

- (id)init {

    self = [super init];

    if (self) {
        _isInsertingCellsToTop = NO;
    }
    return self;
}

- (void)prepareLayout {

    NSLog(@"prepareLayout");
    [super prepareLayout];
}

- (NSArray *)layoutAttributesForElementsInRect:(CGRect)rect {

    NSLog(@"layoutAttributesForElementsInRect");
    self.visableAttributes = [super layoutAttributesForElementsInRect:rect];
    self.offset = 0;
    self.isInsertingCellsToTop = NO;
    return self.visableAttributes;
}

- (void)prepareForCollectionViewUpdates:(NSArray *)updateItems {

    bottomMostVisibleCell = -MAXFLOAT;
    topMostVisibleCell = MAXFLOAT;
    CGRect container = CGRectMake(self.collectionView.contentOffset.x, self.collectionView.contentOffset.y, self.collectionView.frame.size.width, self.collectionView.frame.size.height);

    [self.visableAttributes  enumerateObjectsUsingBlock:^(UICollectionViewLayoutAttributes *attributes, NSUInteger idx, BOOL *stop) {

        CGRect currentCellFrame =  attributes.frame;
        CGRect containerFrame = container;

        if(CGRectIntersectsRect(containerFrame, currentCellFrame)) {
            float x = attributes.indexPath.row;
            if (x < topMostVisibleCell) topMostVisibleCell = x;
            if (x > bottomMostVisibleCell) bottomMostVisibleCell = x;
        }
    }];

    NSLog(@"prepareForCollectionViewUpdates");
    [super prepareForCollectionViewUpdates:updateItems];
    for (UICollectionViewUpdateItem *updateItem in updateItems) {
        switch (updateItem.updateAction) {
            case UICollectionUpdateActionInsert:{
                NSLog(@"UICollectionUpdateActionInsert %ld",updateItem.indexPathAfterUpdate.row);
                if (topMostVisibleCell>updateItem.indexPathAfterUpdate.row) {
                    UICollectionViewLayoutAttributes * newAttributes = [self layoutAttributesForItemAtIndexPath:updateItem.indexPathAfterUpdate];
                    self.offset += (newAttributes.size.height + self.minimumLineSpacing);
                    self.isInsertingCellsToTop = YES;
                }
                break;
            }
            case UICollectionUpdateActionDelete: {
                NSLog(@"UICollectionUpdateActionDelete %ld",updateItem.indexPathBeforeUpdate.row);
                if (topMostVisibleCell>updateItem.indexPathBeforeUpdate.row) {
                    UICollectionViewLayoutAttributes * newAttributes = [self layoutAttributesForItemAtIndexPath:updateItem.indexPathBeforeUpdate];
                    self.offset -= (newAttributes.size.height + self.minimumLineSpacing);
                    self.isInsertingCellsToTop = YES;
                }
                break;
            }
            case UICollectionUpdateActionMove:
                NSLog(@"UICollectionUpdateActionMoveB %ld", updateItem.indexPathBeforeUpdate.row);
                break;
            default:
                NSLog(@"unhandled case: %ld", updateItem.indexPathBeforeUpdate.row);
                break;
        }
    }

    if (self.isInsertingCellsToTop) {
        if (self.collectionView) {
            [CATransaction begin];
            [CATransaction setDisableActions:YES];
        }
    }
}

- (void)finalizeCollectionViewUpdates {

    CGPoint newOffset = CGPointMake(self.collectionView.contentOffset.x, self.collectionView.contentOffset.y + self.offset);

    if (self.isInsertingCellsToTop) {
        if (self.collectionView) {
            self.collectionView.contentOffset = newOffset;
            [CATransaction commit];
        }
    }
}
3

Inspired by Bryan Pratte's solution I developed subclass of UICollectionViewFlowLayout to get chat behavior without turning collection view upside-down. This layout is written in Swift 3 and absolutely usable with RxSwift and RxDataSources because UI is completely separated from any logic or binding.

Three things were important for me:

  1. If there is a new message, scroll down to it. It doesn't matter where you are in the list in this moment. Scrolling is realized with setContentOffset instead of scrollToItemAtIndexPath.
  2. If you do "Lazy Loading" with older messages, then the scroll view shouldn't change and stays exactly where it is.
  3. Add exceptions for the beginning. The collection view should behave "normal" till there are more messages than space on the screen.

My solution: https://gist.github.com/jochenschoellig/04ffb26d38ae305fa81aeb711d043068

3

While all solutions above are worked for me, the main reason of those to fail is that when user is scrolling while those items are being added, scroll will either stop or there'll be noticeable lag Here is a solution that helps to maintain (visual)scroll position while adding items to the top.

class Layout: UICollectionViewFlowLayout {

    var heightOfInsertedItems: CGFloat = 0.0

    override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint {
        var offset = proposedContentOffset
        offset.y +=  heightOfInsertedItems
        heightOfInsertedItems = 0.0
        return offset
    }

    override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
        var offset = proposedContentOffset
        offset.y += heightOfInsertedItems
        heightOfInsertedItems = 0.0
        return offset
    }

    override func prepare(forCollectionViewUpdates updateItems: [UICollectionViewUpdateItem]) {
        super.prepare(forCollectionViewUpdates: updateItems)
        var totalHeight: CGFloat = 0.0
        updateItems.forEach { item in
            if item.updateAction == .insert {
                if let index = item.indexPathAfterUpdate {
                    if let attrs = layoutAttributesForItem(at: index) {
                        totalHeight += attrs.frame.height
                    }
                }
            }
        }

        self.heightOfInsertedItems = totalHeight
    }
}

This layout remembers the height of items those are about to be inserted, and then next time, when layout will be asked for offset, it will compensate offset by the height of added items.

tt.Kilew
  • 5,954
  • 2
  • 33
  • 51
2

Not the most elegant but quite simple and working solution I stuck with for now. Works only with linear layout (not grid) but it's fine for me.

// retrieve data to be inserted
NSArray *fetchedObjects = [managedObjectContext executeFetchRequest:fetchRequest error:nil];
NSMutableArray *objects = [fetchedObjects mutableCopy];
[objects addObjectsFromArray:self.messages];

// self.messages is a DataSource array
self.messages = objects;

// calculate index paths to be updated (we are inserting 
// fetchedObjects.count of objects at the top of collection view)
NSMutableArray *indexPaths = [NSMutableArray new];
for (int i = 0; i < fetchedObjects.count; i ++) {
    [indexPaths addObject:[NSIndexPath indexPathForItem:i inSection:0]];
}

// calculate offset of the top of the displayed content from the bottom of contentSize
CGFloat bottomOffset = self.collectionView.contentSize.height - self.collectionView.contentOffset.y;

// performWithoutAnimation: cancels default collection view insertion animation
[UIView performWithoutAnimation:^{

    // capture collection view image representation into UIImage
    UIGraphicsBeginImageContextWithOptions(self.collectionView.bounds.size, NO, 0);
    [self.collectionView drawViewHierarchyInRect:self.collectionView.bounds afterScreenUpdates:YES];
    UIImage *snapshotImage = UIGraphicsGetImageFromCurrentImageContext();
    UIGraphicsEndImageContext();

    // place the captured image into image view laying atop of collection view
    self.snapshot.image = snapshotImage;
    self.snapshot.hidden = NO;

    [self.collectionView performBatchUpdates:^{
        // perform the actual insertion of new cells
        [self.collectionView insertItemsAtIndexPaths:indexPaths];
    } completion:^(BOOL finished) {
        // after insertion finishes, scroll the collection so that content position is not
        // changed compared to such prior to the update
        self.collectionView.contentOffset = CGPointMake(0, self.collectionView.contentSize.height - bottomOffset);
        [self.collectionView.collectionViewLayout invalidateLayout];

        // and hide the snapshot view
        self.snapshot.hidden = YES;
    }];
}];
marvin_yorke
  • 3,469
  • 4
  • 25
  • 35
2
if ([newMessages count] > 0)
{
    [self.collectionView reloadData];

    if (hadMessages)
        [self.collectionView scrollToItemAtIndexPath:[NSIndexPath indexPathForItem:[newMessages count] inSection:0] atScrollPosition:UICollectionViewScrollPositionTop animated:NO];
}

This seems to be working so far. Reload the collection, scroll the previously first message to the top without animation.

Chad Podoski
  • 958
  • 9
  • 11
2

I managed to write a solution which works for cases when inserting cells at the top and bottom at the same time.

  1. Save the position of the top visible cell. Compute the height of the cell which is underneath the navBar (the top view. in my case it is the self.participantsView)
// get the top cell and save frame
NSMutableArray<NSIndexPath*> *visibleCells = [self.collectionView indexPathsForVisibleItems].mutableCopy;
NSSortDescriptor *sortDescriptor = [[NSSortDescriptor alloc] initWithKey:@"item" ascending:YES];
[visibleCells sortUsingDescriptors:@[sortDescriptor]];

ChatMessage *m = self.chatMessages[visibleCells.firstObject.item];
UICollectionViewCell *topCell = [self.collectionView cellForItemAtIndexPath:visibleCells.firstObject];
CGRect topCellFrame = topCell.frame;
CGRect navBarFrame = [self.view convertRect:self.participantsView.frame toView:self.collectionView];
CGFloat offset = CGRectGetMaxY(navBarFrame) - topCellFrame.origin.y;
  1. Reload your data.
[self.collectionView reloadData];
  1. Get the new position of the item. Get the attributes for that index. Extract the offset and change contentOffset of the collectionView.
// scroll to the old cell position
NSUInteger messageIndex = [self.chatMessages indexOfObject:m];

UICollectionViewLayoutAttributes *attr = [self.collectionView layoutAttributesForItemAtIndexPath:[NSIndexPath indexPathForItem:messageIndex inSection:0]];

self.collectionView.contentOffset = CGPointMake(0, attr.frame.origin.y + offset);
grigorievs
  • 162
  • 6
2
// stop scrolling
setContentOffset(contentOffset, animated: false)

// calculate the offset and reloadData
let beforeContentSize = contentSize
reloadData()
layoutIfNeeded()
let afterContentSize = contentSize

// reset the contentOffset after data is updated
let newOffset = CGPoint(
  x: contentOffset.x + (afterContentSize.width - beforeContentSize.width),
  y: contentOffset.y + (afterContentSize.height - beforeContentSize.height))
setContentOffset(newOffset, animated: false)
Levan Karanadze
  • 739
  • 1
  • 11
  • 21
1

I found the five steps work seamlessly:

  1. Prepare data for your new cells, and insert the data as appropriate

  2. Tell UIView to stop animation

    UIView.setAnimationsEnabled(false)
    
  3. Actually insert those cells

    collectionView?.insertItems(at: indexPaths)
    
  4. Scroll the collection view (which is a subclass of UIScrollView)

    scrollView.contentOffset.y += CELL_HEIGHT * CGFloat(ITEM_COUNT)
    

    Notice to substitute CELL_HEIGHT with the height of your cells (which is only easy if cells are of a fixed size). It is important to add any cell-to-cell margin / insets.

  5. Remember to tell UIView to start animation again:

    UIView.setAnimationsEnabled(true)
    
Teng L
  • 297
  • 3
  • 17
1

A few of the suggested approaches had varying degrees of success for me. I eventually used a variation of the subclassing and prepareLayout option Peter Stajger putting my offset correction in finalizeCollectionViewUpdates. However today as I was looking at some additional documentation I found targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) and I think that feels a lot more like the intended location for this type of correction. So this is my implementation using that. Note my implmentation was for a horizontal collection but cellsInsertingToTheLeft could be easily updated as cellsInsertingAbove and the offset corrected accordingly.

class GCCFlowLayout: UICollectionViewFlowLayout {

    var cellsInsertingToTheLeft: Int?

    override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint {
        guard let cells = cellsInsertingToTheLeft else { return proposedContentOffset }
        guard let collectionView = collectionView else { return proposedContentOffset }
        let contentOffsetX = collectionView.contentOffset.x + CGFloat(cells) * (collectionView.bounds.width - 45 + 8)
        let newOffset = CGPoint(x: contentOffsetX, y: collectionView.contentOffset.y)
        cellsInsertingToTheLeft = nil
        return newOffset
    }
}
SuperGuyAbe
  • 525
  • 1
  • 5
  • 5
1

Based on @Steven answer, I managed to make insert cell with scroll to the bottom, without any flickering (and using auto cells), tested on iOS 12

    let oldOffset = self.collectionView!.contentOffset
    let oldOffsetDelta = self.collectionView!.contentSize.height - self.collectionView!.contentOffset.y

    CATransaction.begin()
    CATransaction.setCompletionBlock {
        self.collectionView!.setContentOffset(CGPoint(x: 0, y: self.collectionView!.contentSize.height - oldOffsetDelta), animated: true)
    }
        collectionView!.reloadData()
        collectionView!.layoutIfNeeded()
        self.collectionView?.setContentOffset(oldOffset, animated: false)
    CATransaction.commit()
HelloimDarius
  • 695
  • 5
  • 23
0

I have used the @James Martin approach, but if you use coredata and NSFetchedResultsController the right approach is store the number of earlier messages loaded in _earlierMessagesLoaded and check the value in the controllerDidChangeContent:

#pragma mark - NSFetchedResultsController

- (void)controllerDidChangeContent:(NSFetchedResultsController *)controller
{
    if(_earlierMessagesLoaded)
    {
        __block NSMutableArray * indexPaths = [NSMutableArray new];
        for (int i =0; i<[_earlierMessagesLoaded intValue]; i++)
        {
            [indexPaths addObject:[NSIndexPath indexPathForRow:i inSection:0]];
        }

        CGFloat bottomOffset = self.collectionView.contentSize.height - self.collectionView.contentOffset.y;

        [CATransaction begin];
        [CATransaction setDisableActions:YES];

        [self.collectionView  performBatchUpdates:^{

            [self.collectionView insertItemsAtIndexPaths:indexPaths];

        } completion:^(BOOL finished) {

            self.collectionView.contentOffset = CGPointMake(0, self.collectionView.contentSize.height - bottomOffset);
            [CATransaction commit];
            _earlierMessagesLoaded = nil;
        }];
    }
    else
        [self finishReceivingMessageAnimated:NO];
}
Rishil Patel
  • 1,977
  • 3
  • 14
  • 30
Kappe
  • 9,217
  • 2
  • 29
  • 41
-1
CGPoint currentOffset = _collectionView.contentOffset;
CGSize contentSizeBeforeInsert = [_collectionView.collectionViewLayout collectionViewContentSize];

[_collectionView reloadData];

CGSize contentSizeAfterInsert = [_collectionView.collectionViewLayout collectionViewContentSize];

CGFloat deltaHeight = contentSizeAfterInsert.height - contentSizeBeforeInsert.height;
currentOffset.y += MAX(deltaHeight, 0);

_collectionView.contentOffset = currentOffset;
Bogdan Onu
  • 129
  • 5