UICollectionView insert cells above maintaining po

2020-01-29 03:30发布

问题:

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?

回答1:

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];


回答2:

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


回答3:

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
}


回答4:

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.



回答5:

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!



回答6:

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


回答7:

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


回答8:

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];
        }
    }
}


回答9:

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



回答10:

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;
    }];
}];


回答11:

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.



回答12:

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);


回答13:

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)


回答14:

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.



回答15:

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)
    


回答16:

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


回答17:

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


回答18:

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];
}


回答19:

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;