UICollectionView在保持位置之上插入单元格(如Messages.app)

时间:2014-08-28 11:58:36

标签: ios uicollectionview

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

20 个答案:

答案 0 :(得分:55)

这是我使用的技术。我发现其他人会引起奇怪的副作用,例如屏幕闪烁:

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

答案 1 :(得分:22)

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

定义变量

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

覆盖 prepareLayout() 并在致电超级之后

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
}

答案 2 :(得分:21)

詹姆斯·马丁的精彩版本转换为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 :(得分:12)

我是用两行代码完成的(尽管它是在UITableView上),但我认为你能够以同样的方式做到这一点。

我将桌面视图旋转180度。

然后我也将每个tableview单元旋转了180度。

这意味着我可以将它视为标准的从上到下的桌子,但底部被视为顶部。

答案 4 :(得分:7)

添加到Fogmeister的答案(使用代码),最干净的方法是反转(翻转)UICollectionView,以便您有一个粘性到底部的滚动视图而不是最佳。正如Fogmeister指出的那样,这也适用于UITableView

- (void)viewDidLoad
{
    [super viewDidLoad];

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

}

在斯威夫特:

override func viewDidLoad() {
    super.viewDidLoad()

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

这样做的副作用是也可以将您的细胞颠倒显示,因此您也必须翻转它们。所以我们像这样转移trasform(cell.transform = collectionView.transform):

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

    cell.transform = collectionView.transform;

    return cell;
}

在斯威夫特:

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

    cell.transform = collectionView.transform

    return cell
}

最后,在此设计下进行开发时要记住的主要事项是委托中的NSIndexPath参数是相反的。因此indexPath.row == 0collectionView底部的行,通常位于顶部。

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

以为我会为Fogmeister的奇妙答案添加一些代码上下文!

答案 5 :(得分:3)

这是Peter的解决方案的略微调整版本(子流程布局,没有倒置,轻量级方法)。它是 Swift 3 。注意UIView.animate持续时间为零 - 这是为了允许单元格的偶数/奇数的动画(行上的内容)动画,但是停止视口偏移的动画更改(看起来很糟糕)

用法:

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

答案 6 :(得分:3)

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

答案 7 :(得分:3)

Bryan Pratte 的解决方案的启发,我开发了UICollectionViewFlowLayout的子类,以获得聊天行为,而不会将集合视图颠倒过来。此布局使用 Swift 3 编写,绝对可用于 RxSwift RxDataSources ,因为UI与任何逻辑或绑定完全分开。

有三件事对我很重要:

  1. 如果有新消息,请向下滚动到该消息。在这一刻,你在列表中的位置并不重要。使用<div class="row no-margin white max-width"> <div class="col-xs-12 col-sm-12 col-sm-push-12 col-md-6 no-padding"> <div class="image"> A </div> </div> <div class="col-xs-12 col-sm-12 col-sm-pull-12 col-md-6 no-padding"> <div class="item"> <div class="title"> </div> <div class="info"> B </div> </div> </div> </div> 代替setContentOffset实现滚动。
  2. 如果您对旧邮件执行“延迟加载”,则滚动视图不应更改并保持原样。
  3. 为开头添加例外。集合视图应该表现“正常”,直到屏幕上的消息多于空格。
  4. 我的解决方案: https://gist.github.com/jochenschoellig/04ffb26d38ae305fa81aeb711d043068

答案 8 :(得分:3)

爱詹姆斯·马丁的解决方案。但对我来说,当在特定内容窗口的上方/下方插入/删除时,它开始崩溃。我接受了UICollectionViewFlowLayout的子类化以获得我想要的行为。希望这有助于某人。任何反馈赞赏:)

@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 :(得分: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)

答案 10 :(得分:2)

我设法编写了一个解决方案,适用于同时在顶部和底部插入单元格的情况。

  1. 保存顶部可见单元格的位置。计算navBar下面的单元格的高度(顶视图。在我的例子中,它是self.participantsView)
  2. [self.collectionView reloadData];
    
    1. 重新加载您的数据。
    2. // 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);
      
      1. 获取该项目的新位置。获取该索引的属性。提取偏移量并更改collectionView的contentOffset。
      2. function my_theme_infinite_scroll_render() {
        get_template_part( 'content' );
        }
        add_theme_support( 'infinite-scroll', array(
        'container' => 'content',
        'render'    => 'my_theme_infinite_scroll_render',
        'posts_per_page' => 6,    
        'footer' => 'false'
        ) );
        

答案 11 :(得分:2)

if ([newMessages count] > 0)
{
    [self.collectionView reloadData];

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

到目前为止,这似乎有效。重新加载集合,将之前的第一条消息滚动到顶部,不带动画。

答案 12 :(得分:2)

不是我现在坚持的最优雅但非常简单且有效的解决方案。仅适用于线性布局(不是网格),但它对我来说很好。

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

答案 13 :(得分:1)

虽然上面的所有解决方案都适用于我,但失败的主要原因是当用户在添加这些项目时滚动时,滚动将停止或显着滞后 这是一个有助于在将项目添加到顶部时保持(可视)滚动位置的解决方案。

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

此布局会记住即将插入的项目的高度,然后下次,当要求布局偏移时,它将补偿所添加项目的高度偏移。

答案 14 :(得分:1)

这是我从JSQMessagesViewController: How maintain scroll position?学到的。非常简单,有用,没有闪烁!

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

答案 15 :(得分:1)

我发现这五个步骤无缝合作:

  1. 准备新单元格的数据,并根据需要插入数据

  2. 告诉UIView停止动画

    UIView.setAnimationsEnabled(false)
    
  3. 实际插入这些单元格

    collectionView?.insertItems(at: indexPaths)
    
  4. 滚动集合视图(UIScrollView

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

    注意用单元格的高度替换CELL_HEIGHT(如果单元格大小固定,则很容易)。添加任何单元到单元格的边距/插入内容非常重要。

  5. 请记得告诉UIView再次开始动画:

    UIView.setAnimationsEnabled(true)
    

答案 16 :(得分:0)

我使用了@James Martin方法,但如果您使用coredataNSFetchedResultsController,则正确的方法是存储 _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];
}

答案 17 :(得分:0)

一些建议的方法对我而言具有不同程度的成功。我最终使用了子类和prepareLayout选项Peter Stajger的变体,将我的偏移量校正放在了finalizeCollectionViewUpdates中。但是,今天,当我查看一些其他文档时,发现targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint),我觉得这更像是这种校正的预期位置。这就是我使用的实现。请注意,我的实现是针对水平集合,但是cellsInsertingToTheLeft可以很容易地更新为cellsInsertingAbove,并且可以相应地校正偏移量。

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

答案 18 :(得分:0)

基于@Steven的答案,我设法使插入单元格滚动到底部,没有任何闪烁(并使用自动单元格),并在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()

答案 19 :(得分:-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;