UICollectionView插入上面维护位置的单元格(如Messages.app)

默认情况下,Collection View在插入单元格时保持内容偏移。 另一方面,我想插入单元格上方的当前显示的,以便他们出现在屏幕上方边缘像Messages.app当加载早期的消息。 有没有人知道如何实现它?

这是我使用的技术。 我发现别人会造成奇怪的副作用,如屏幕闪烁:

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

詹姆斯·马丁的奇妙版本转换为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() }) 

我的方法利用了分层stream布局。 这意味着你不必在视图控制器中破解滚动/布局代码。 想法是,只要你知道你在顶部插入单元格,你就设置了自定义属性,你将标记下一个布局更新将把单元格插入顶部,并且在更新之前记住内容大小。 然后重写prepareLayout()并在那里设置所需的内容偏移量。 它看起来像这样:

定义variables

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

重写 prepareLayout() 并调用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 } 

我在两行代码中完成了这项工作(虽然它在UITableView上),但是我认为你可以用同样的方法来完成。

我旋转180度的桌面。

然后我旋转180度的每个桌子单元格也。

这意味着我可以把它作为一个标准的顶部到底部表,但底部被视为顶部。

添加到Fogmeister的答案(与代码),最简洁的方法是颠倒(颠倒)的UICollectionView,让你有一个粘性的底部,而不是顶部的滚动视图。 Fogmeister指出,这也适用于UITableView。

 - (void)viewDidLoad { [super viewDidLoad]; self.collectionView.transform = CGAffineTransformMake(1, 0, 0, -1, 0, 0); } 

在Swift中:

 override func viewDidLoad() { super.viewDidLoad() collectionView.transform = CGAffineTransformMake(1, 0, 0, -1, 0, 0) } 

这也有副作用,也显示你的细胞倒置,所以你不得不翻转这些。 所以我们像这样传输cell.transform = collectionView.transformcell.transform = collectionView.transform ):

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

在Swift中:

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

最后,在这个devise下开发时要记住的主要事情是NSIndexPath中的NSIndexPath参数是相反的。 因此, indexPath.row == 0是collectionView底部的行,通常位于顶部。

这种技术被用于许多开源项目中,以产生所描述的行为,包括由Slack维护的stream行的SlackTextViewController( https://github.com/slackhq/SlackTextViewController

以为我会添加一些代码上下文Fogmeister的梦幻般的答案!

爱James Martin的解决scheme。 但对我来说,插入/删除特定内容窗口上方/下方时开始崩溃。 我采取了inheritanceUICollectionViewFlowLayout子类来获得我想要的行为。 希望这有助于某人。 任何反馈赞赏:)

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

Bryan Pratte解决scheme的启发,我开发了UICollectionViewFlowLayout的子类来获取聊天行为, 而不会颠倒收集视图 。 这个布局是用Swift 3编写的,对于RxSwiftRxDataSources是绝对可用的,因为UI与任何逻辑或绑定完全分离。

有三件事对我很重要:

  1. 如果有新消息,请向下滚动。 这一刻你在列表中的位置并不重要。 使用setContentOffset而不是scrollToItemAtIndexPath来实现滚动。
  2. 如果使用旧消息进行“延迟加载”,那么滚动视图不应该更改,而是保持原来的位置。
  3. 添加例外的开始。 收集视图应该performance“正常”,直到屏幕上的空间比消息更多。

我的解决scheme: https //gist.github.com/jochenschoellig/04ffb26d38ae305fa81aeb711d043068

不是最优雅,但相当简单,我现在坚持工作的解决scheme。 只使用线性布局(不是网格),但对我来说很好。

 // 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; }]; }]; 
 if ([newMessages count] > 0) { [self.collectionView reloadData]; if (hadMessages) [self.collectionView scrollToItemAtIndexPath:[NSIndexPath indexPathForItem:[newMessages count] inSection:0] atScrollPosition:UICollectionViewScrollPositionTop animated:NO]; } 

这似乎工作到目前为止。 重新加载集合,将以前的第一条消息滚动到顶部而不用animation。

以下是Peter的解决scheme(子类stream布局,没有颠倒,轻量级的方法)的微调版本。 这是Swift 3 。 注意UIView.animate持续时间为零 – 这是为了允许单元格的animation(连续行)animation,但停止视口偏移量的animation变化(这看起来很糟糕)

用法:

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

这里是完整的ContentSizePreservingFlowLayout

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

我设法编写一个解决scheme,同时在顶部和底部插入单元格的情况下起作用。

  1. 保存顶部可见单元格的位置。 计算navBar下面的单元格的高度(顶视图,在我的情况下是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. 重新加载您的数据。
 [self.collectionView reloadData]; 
  1. 获取物品的新位置。 获取该索引的属性。 提取偏移量并更改collectionView的contentOffset。
 // 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); 

Swift 3版本代码:基于James Martin的回答

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

我已经使用了@James Martin方法,但是如果使用核心数据和NSFetchedResultsController,则正确的方法是存储在_earlierMessagesLoaded中加载的较早消息的数量,并检查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]; } 

虽然以上所有的解决scheme都为我工作,这些失败的主要原因是,当用户正在滚动而这些项目正在添加,滚动将停止或将有明显的滞后这是一个解决scheme,有助于维护(视觉)将项目添加到顶部时滚动位置。

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

这个布局会记住那些将要插入的物品的高度,然后下一次,当布局被要求补偿时,它会补偿补偿物品的高度。

这是我从JSQMessagesViewController中学到的:如何保持滚动位置? 。 非常简单,有用,没有闪烁!

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