UICollectionView inserts cells above the supporting position (e.g. Messages.app)

By default, Collection View supports content offset when inserting cells. On the other hand, I would like to insert cells above the ones currently displayed so that they appear above the top of the screen, for example Messages.app, when loading earlier messages. Does anyone know how to achieve it?

+31
ios uicollectionview
Aug 28 '14 at 11:58 a.m.
source share
19 answers

This is the technique that I use. I found that others were causing strange side effects such as screen flickering:

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]; 
+55
Oct. 16 '14 at 10:12
source share

James Martins 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() }) 
+21
Sep 21 '15 at 9:38
source share

My approach uses a subclass stream layout. This means that you do not need to crack the scroll / layout code in the view controller. The idea is that whenever you know that you insert cells on top, you set a custom property, you note that the next layout update will insert cells up, and you remember the size of the content before the update. Then you override prepareLayout () and set the desired content offset. 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 } 
+21
Dec 10 '15 at 2:37
source share

I did this in two lines of code (although it was in a UITableView), but I think you can do it the same way.

I rotated the table 180 degrees.

Then I rotated each cell of the table 180 degrees.

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

+12
Aug 28 '14 at 12:01
source share

In addition to Fogmeister's answer (with code), the cleanest approach is to invert (flip) a UICollectionView so that you have a scroll view that sticks to the bottom, not the top. This also works for a 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 should also flip them. Therefore, we pass trasform ( cell.transform = collectionView.transform ) as follows:

 - (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 } 

Finally, the main thing to remember when developing as part of this project is that the NSIndexPath parameters in the NSIndexPath delegates NSIndexPath reversed. Thus, indexPath.row == 0 is the line at the bottom of the collectionView where it is usually at the top.

This method is used in many open source projects to create the described behavior, including the popular SlackTextViewController ( https://github.com/slackhq/SlackTextViewController ) supported by Slack

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

+6
Aug 16 '15 at 18:48
source share

The love of James Martins. But for me, it started to break when inserting / deleting above / below a certain content window. I took a hit in a subclass of UICollectionViewFlowLayout to get the behavior I wanted. Hope this helps someone. Any rating is 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
Jul 22 '16 at 19:20
source share

Inspired by Bryan Pratte's solution , I developed a subclass of UICollectionViewFlowLayout to get chat behavior without turning the collection upside down. This layout is written in Swift 3 and is absolutely applicable with RxSwift and RxDataSources , because the interface is completely separate from any logic or binding.

Three things were important to me:

  • If a new message appears, scroll down. It doesn't matter where you are on the list at that moment. Scrolling is done using setContentOffset instead of scrollToItemAtIndexPath .
  • If you are doing Lazy Loading with older posts, the scroll view should not change and remains where it is.
  • Add exceptions to get you started. The collection view should behave "normally" as long as there are more messages on the screen than on the screen.

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

+3
Jan 19 '17 at 16:15
source share

Here's a slightly modified version of Peter's solution (subclassing the layout of the stream, without an inverted, easy approach). This is Swift 3 . Notice UIView.animate with zero duration - so that the parity / odd animation of the cells (which is on the line) UIView.animate to life, but stop the viewport shift animation (which will look awful)

Using:

  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 the ContentSizePreservingFlowLayout as a whole:

  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 } } } 
+3
May 30 '17 at 20:41
source share

Not the most elegant, but rather simple and effective solution that I'm stuck on. It only works with linear layout (not mesh), but that is 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; }]; }]; 
+2
Aug 30 '14 at 5:44
source share
 if ([newMessages count] > 0) { [self.collectionView reloadData]; if (hadMessages) [self.collectionView scrollToItemAtIndexPath:[NSIndexPath indexPathForItem:[newMessages count] inSection:0] atScrollPosition:UICollectionViewScrollPositionTop animated:NO]; } 

It seems to work so far. Reload the collection, scroll the previous message to the top without animation.

+2
Sep 18 '14 at 22:33
source share

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

  • Save the position of the top visible cell. Calculate the height of the cell that is under the navBar (top view, in my case it is 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 the data.
 [self.collectionView reloadData]; 
  1. Get a new item position. Get attributes for this index. Extract the offset and change the contentOffset for the collection.
 // 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); 
+2
May 21 '16 at 12:48
source share

Swift 3 Version Code: Based on James Martin Response

  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 i in 0..<amount { let index = 0 + i 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() }) 
+2
Mar 19 '17 at 13:15
source share

Despite the fact that all the solutions described above work for me, the main reason for the failure is that when the user scrolls while these elements are added, the scroll stops or there will be a noticeable lag Here is a solution that helps maintain the (visual) scroll position when adding items up.

 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 the elements to be inserted, and then the next time the layout is requested for offset, it compensates for the height offset of the added elements.

+1
Nov 10 '17 at 13:51 on
source share

This is what I learned from JSQMessagesViewController: how to maintain scroll position? . Very simple, useful and does not 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) 
+1
Dec 22 '17 at 8:01
source share

I found that the five steps work without problems:

  1. Prepare data for your new cells and insert data accordingly

  2. Tell UIView to stop the animation.

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

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

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

    Pay attention to replacing CELL_HEIGHT with the height of your cells (which is easy to do if the cells have a fixed size). It is important to add any intercellular fields / inserts.

  5. Remember to tell UIView to start the animation again:

     UIView.setAnimationsEnabled(true) 
+1
May 26 '18 at 16:25
source share

I used the @James Martin approach, but if you use coredata and NSFetchedResultsController correct approach is to save the number of previously loaded messages loaded in _earlierMessagesLoaded and check the value in 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]; } 
0
Feb 10 '15 at 12:43
source share

Some of the proposed approaches have varying degrees of success for me. In the end, I used the subclass and prepareLayout Peter Stajger, putting my offset correction in finalizeCollectionViewUpdates . However, today, when I was looking through some additional documentation, I found targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) and I think this is much more like the intended location for this type of fix. So this is my implementation using this. Please note that my implementation was for a horizontal collection, but cellsInsertingToTheLeft can be easily updated as cellsInsertingAbove and the offset is adjusted 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 } } 
0
Jul 09 '18 at 17:31
source share

Based on @Steven's answer, I was able to insert a cell with scroll down, without flickering (and using auto elements), 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() 
0
Sep 27 '18 at 8:03
source share
 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; 
-one
02 Oct '15 at 2:51
source share



All Articles