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 appea
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;
}];
}];
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.
I found the five steps work seamlessly:
Prepare data for your new cells, and insert the data as appropriate
Tell UIView
to stop animation
UIView.setAnimationsEnabled(false)
Actually insert those cells
collectionView?.insertItems(at: indexPaths)
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.
Remember to tell UIView
to start animation again:
UIView.setAnimationsEnabled(true)
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
}
}
}
I managed to write a solution which works for cases when inserting cells at the top and bottom at the same time.
// 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;
[self.collectionView reloadData];
// 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);
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
}
}