动态设置UICollectionView布局导致contentOffset变化无法解释

73

根据苹果的文档(并在 WWDC 2012 上进行宣传),可以动态设置 UICollectionView 的布局,甚至可以对更改进行动画处理:

通常在创建 collection view 时会指定布局对象,但也可以在运行时动态更改 collection view 的布局。布局对象存储在 collectionViewLayout 属性中。直接设置此属性将立即更新布局,而不会对更改进行动画处理。如果要对更改进行动画处理,则必须调用 setCollectionViewLayout:animated: 方法。

然而,在实践中,我发现 UICollectionViewcontentOffset 进行了难以理解甚至无效的更改,导致单元格移动出错,使该功能几乎无法使用。为了说明问题,我编写了下面的示例代码,可以附加到故事板中的默认集合视图控制器上:

#import <UIKit/UIKit.h>

@interface MyCollectionViewController : UICollectionViewController
@end

@implementation MyCollectionViewController

- (void)viewDidLoad
{
    [super viewDidLoad];
    [self.collectionView registerClass:[UICollectionViewCell class] forCellWithReuseIdentifier:@"CELL"];
    self.collectionView.collectionViewLayout = [[UICollectionViewFlowLayout alloc] init];
}

- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section
{
    return 1;
}

- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath
{
    UICollectionViewCell *cell = [self.collectionView dequeueReusableCellWithReuseIdentifier:@"CELL" forIndexPath:indexPath];
    cell.backgroundColor = [UIColor whiteColor];
    return cell;
}

- (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath
{
    NSLog(@"contentOffset=(%f, %f)", self.collectionView.contentOffset.x, self.collectionView.contentOffset.y);
    [self.collectionView setCollectionViewLayout:[[UICollectionViewFlowLayout alloc] init] animated:YES];
    NSLog(@"contentOffset=(%f, %f)", self.collectionView.contentOffset.x, self.collectionView.contentOffset.y);
}

@end

viewDidLoad中,控制器设置默认的UICollectionViewFlowLayout并显示单个单元格在屏幕上。当单元格被选择时,控制器创建另一个默认的UICollectionViewFlowLayout,并使用animated:YES标志将其设置在集合视图上。预期行为是单元格不会移动。然而,实际行为是单元格滚动到屏幕外,此时甚至不可能将单元格滚动回到屏幕上。

查看控制台日志可以发现,contentOffset不可解地更改了(在我的项目中,从(0, 0)更改为(0, 205))。 我在非动画情况下的解决方案中发布了解决方法(i.e. animated:NO),但由于我需要动画,因此很想知道是否有任何解决方案或解决方法适用于带有动画的情况。

顺便说一句,我测试了自定义布局,并得到了相同的行为。


1
终于解决了。@Isaacliu 给出了正确的答案。我已经使用了现代的 Swift 版本。 - Fattie
setCollectionViewLayout(_:animated:) 对我非常有用。只需像这样做 collectionView.setCollectionViewLayout(expanded ? self.verticalFlowLayout : self.horizontalFlowLayout, animated: true) - aheze
10个回答

38

UICollectionViewLayout 包含可重载方法 targetContentOffsetForProposedContentOffset:,允许在布局更改期间提供正确的内容偏移,并且可以正确地进行动画处理。此功能适用于 iOS 7.0 及以上版本。


希望我可以多次点赞。对于某些涉及此问题的情况的子集(例如,我想保持当前的contentOffset),这是完美的解决方案。感谢@cdemiris99。 - sam-w
1
这解决了我的直接问题,但我在想为什么?感觉我不应该这样做。我会更好地理解这里的情况,并编写一个错误报告。 - bpapa
你真是个天才,这完美地解决了问题! - Blane Townsend
1
重写函数 targetContentOffsetForProposedContentOffset(proposedContentOffset: CGPoint) -> CGPoint { if let collectionView = self.collectionView { let currentContentOffset = collectionView.contentOffset if currentContentOffset.y < proposedContentOffset.y { return currentContentOffset } } return proposedContentOffset } 已解决问题。感谢提示。 - Massmaker

33

我已经为此烦恼了几天,并找到了适合我的情况的解决方案,希望能帮到你。

我的情况是有一个类似iPad上照片应用程序中折叠式照片布局。 它显示带有照片的相册,当您点击相册时,它会展开照片。因此,我有两个单独的UICollectionViewLayouts,并使用 [self.collectionView setCollectionViewLayout:myLayout animated:YES] 在它们之间切换。 我遇到了与您完全相同的问题,即在动画之前,单元格会跳动,后来发现问题出在contentOffset上。 我尝试了各种方法来解决contentOffset的问题,但它仍然在动画期间跳动。 Tyler上面的解决方案有效,但它仍然会影响动画。

然后我注意到只有在屏幕上有几个相册的情况下才会发生这种情况,而不足以填满屏幕。 我的布局重写了-(CGSize)collectionViewContentSize作为建议。 当只有几个相册时,集合视图内容大小小于视图内容大小。 这会在我在集合布局之间切换时导致跳动。

因此,我在我的布局上设置了一个名为minHeight的属性,并将其设置为集合视图父级的高度。 然后,在-(CGSize)collectionViewContentSize中返回之前检查高度,确保高度> =最小高度。

虽然不是真正的解决方案,但现在它可以正常工作。 我建议您尝试将集合视图的contentSize设置为至少等于其包含视图的长度。

编辑: Manicaesar添加了一个简单的解决方法,如果您继承自UICollectionViewFlowLayout:

-(CGSize)collectionViewContentSize { //Workaround
    CGSize superSize = [super collectionViewContentSize];
    CGRect frame = self.collectionView.frame;
    return CGSizeMake(fmaxf(superSize.width, CGRectGetWidth(frame)), fmaxf(superSize.height, CGRectGetHeight(frame)));
}

5
如果你继承自UICollectionViewFlowLayout,那么只需要这样做就可以了:
  • (CGSize)collectionViewContentSize { // 解决方法 CGSize superSize = [super collectionViewContentSize]; CGRect frame = self.collectionView.frame; return CGSizeMake(fmaxf(superSize.width, CGRectGetWidth(frame)), fmaxf(superSize.height, CGRectGetHeight(frame)));
}
- manicaesar
1
manicaesar,这个可以解决问题。应该将其添加到某个答案中,以免在评论中丢失。我已经将其添加到答案中了。 - Joris Mans
manicaesar的解决方案在集合中只有少量项目时运行得非常完美。太好了! - zontar
5
请查看您的布局子类中的targetContentOffsetForProposedContentOffset:和targetContentOffsetForProposedContentOffset:withScrollingVelocity:方法。 - ıɾuǝʞ
任何在这里搜索的人,现在终于有一个适当的解决方案了,2019年。向下滚动到Isaac的答案或我的Swift版本。 - Fattie
显示剩余2条评论

8

2019 实际解决方案

假设您对“汽车”视图有多种布局。

假设您有三个布局。

CarsLayout1: UICollectionViewLayout { ...
CarsLayout2: UICollectionViewLayout { ...
CarsLayout3: UICollectionViewLayout { ...

当你在布局之间进行动画时,它会跳跃

这只是苹果的一个不可避免的错误。在进行动画时,它会跳跃,毫无疑问。

解决方法如下:

您必须拥有一个全局浮点数和以下基类:

var avoidAppleMessupCarsLayouts: CGPoint? = nil

class FixerForCarsLayouts: UICollectionViewLayout {
    
    override func prepareForTransition(from oldLayout: UICollectionViewLayout) {
        avoidAppleMessupCarsLayouts = collectionView?.contentOffset
    }
    
    override func targetContentOffset(
        forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint {
        if avoidAppleMessupCarsLayouts != nil {
            return avoidAppleMessupCarsLayouts!
        }
        return super.targetContentOffset(forProposedContentOffset: proposedContentOffset)
    }
}

以下是“汽车”屏幕的三种布局:

CarsLayout1: FixerForCarsLayouts { ...
CarsLayout2: FixerForCarsLayouts { ...
CarsLayout3: FixerForCarsLayouts { ...

就是这样。现在它可以工作了。


  1. 令人难以置信的晦涩,您可能有不同的“布局集”(用于汽车、狗、房屋等),可能会发生冲突。因此,为每个“集”都像上面一样有一个全局和基类。

  2. 这是由用户@Isaacliu在很多年前发明的。

  3. 一个细节,在Isaacliu的代码片段中,添加了finalizeLayoutTransition。实际上,在逻辑上并不必要。

事实上,在苹果改变其工作方式之前,每次在集合视图布局之间进行动画处理时,确实需要这样做。这就是生活!


1
是的,我的上一个答案不正确。谢谢你指出来!学习永远不会太晚 (: - iWheelBuy
3
@iWheelBuy,过去十年中只有两个人找到了正确的方法:)你是其中之一。你的回答有一个小错误,我刚刚进行了纠正并更新了语法。非常感谢你提供的解决方案!!!!!! - Fattie
到了2022年,这个问题在iOS 15.5上仍然存在,苹果做得好!哈哈。你的答案救了我的命,我希望我能投多于1次的赞。 - Daniel Hu
@DanielHu - 你说得完全正确。这只是苹果公司那些“几十年来一直不修复”的惊人事物之一! - Fattie

7

对于我自己的问题,晚来一步给出答案。

TLLayoutTransitioning 库通过重新使用 iOS7 的交互式转换 API 来执行非交互式布局转换,为此问题提供了一个很好的解决方案。它有效地提供了 setCollectionViewLayout 的替代方法,解决了内容偏移问题,并添加了以下几个功能:

  1. 动画持续时间
  2. 30 多种缓动曲线(由 Warren Moore 的 AHEasing 库 提供)
  3. 多个内容偏移模式

可以将自定义缓动曲线定义为 AHEasingFunction 函数。最终的内容偏移可以按照 Minimal、Center、Top、Left、Bottom 或 Right 放置选项的一个或多个索引路径指定。

要了解我的意思,请尝试在 Examples 工作区中运行 调整大小演示 并尝试更改选项。

用法如下。首先,配置您的视图控制器以返回 TLTransitionLayout 的实例:

- (UICollectionViewTransitionLayout *)collectionView:(UICollectionView *)collectionView transitionLayoutForOldLayout:(UICollectionViewLayout *)fromLayout newLayout:(UICollectionViewLayout *)toLayout
{
    return [[TLTransitionLayout alloc] initWithCurrentLayout:fromLayout nextLayout:toLayout];
}

然后,不要调用setCollectionViewLayout,而是调用在UICollectionView-TLLayoutTransitioning类别中定义的transitionToCollectionViewLayout:toLayout

UICollectionViewLayout *toLayout = ...; // the layout to transition to
CGFloat duration = 2.0;
AHEasingFunction easing = QuarticEaseInOut;
TLTransitionLayout *layout = (TLTransitionLayout *)[collectionView transitionToCollectionViewLayout:toLayout duration:duration easing:easing completion:nil];

此调用启动交互式转换,并在内部使用CADisplayLink回调驱动指定持续时间和缓动函数的转换进度。

下一步是指定最终内容偏移量。您可以指定任意值,但UICollectionView-TLLayoutTransitioning中定义的toContentOffsetForLayout方法提供了一种优雅的方式来计算相对于一个或多个索引路径的内容偏移量。例如,为了让特定单元格尽可能靠近集合视图中心结束,立即在transitionToCollectionViewLayout后进行以下调用:

NSIndexPath *indexPath = ...; // the index path of the cell to center
TLTransitionLayoutIndexPathPlacement placement = TLTransitionLayoutIndexPathPlacementCenter;
CGPoint toOffset = [collectionView toContentOffsetForLayout:layout indexPaths:@[indexPath] placement:placement];
layout.toContentOffset = toOffset;

我在使用补充视图时遇到了一些重大动画问题,然后我找到了这个。非常感谢! - Morrowless

7
这个问题也困扰着我,看起来是过渡代码中的一个 bug。据我所知,它试图聚焦在离过渡前视图布局中心最近的单元格上。然而,如果过渡前的视图中心没有单元格,它仍然会尝试将其居中到过渡后单元格应该在的位置。如果你将 alwaysBounceVertical/Horizontal 设置为 YES,加载一个只有一个单元格的视图,然后执行布局过渡,这一点就非常明显了。
我成功地解决了这个问题,方法是在触发布局更新后,明确告诉集合视图聚焦于特定的单元格(例如第一个可见单元格)。
[self.collectionView setCollectionViewLayout:[self generateNextLayout] animated:YES];

// scroll to the first visible cell
if ( 0 < self.collectionView.indexPathsForVisibleItems.count ) {
    NSIndexPath *firstVisibleIdx = [[self.collectionView indexPathsForVisibleItems] objectAtIndex:0];
    [self.collectionView scrollToItemAtIndexPath:firstVisibleIdx atScrollPosition:UICollectionViewScrollPositionCenteredVertically animated:YES];
}

我在我的示例代码中尝试了这个解决方法,效果非常接近。单元格只是稍微弹了一下。然而,在其他情况下它并没有那么好用。例如,将 numberOfItemsInSection: 更改为返回 100。如果你点击第一个单元格,视图会向下滚动几行。然后,如果你点击第一个可见单元格,视图会滚动回顶部。如果你再次点击第一个单元格,它会保持静止(正确的行为)。如果你点击底部行的单元格,它会反弹。将视图向下滚动几行,点击任何行,视图会滚动回顶部。 - Timothy Moose
1
我发现在 scrollToItemAtIndexPath: 调用中使用 animated:NO 可以让显示效果更好。 - honus
2
看起来他们在iOS7中添加了用于控制此行为的API。 - tyler
@tyler,你指的是哪些新的iOS7 APIs? - brainjam
12
请看一下 UICollectionViewLayout 方法 - (CGPoint)targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset。苹果给我的建议是将我想要聚焦的单元格的目标索引放入布局中(在我的自定义布局扩展中公开一个 NSIndexPath 属性),然后使用此信息返回适当的 targetContentOffset。这种解决方案对我有效。 - tyler
显示剩余2条评论

5

简单易懂。

在同一动画块中,使您的新布局和collectionView的contentOffset同时进行动画。

[UIView animateWithDuration:0.3 animations:^{
                                 [self.collectionView setCollectionViewLayout:self.someLayout animated:YES completion:nil];
                                 [self.collectionView setContentOffset:CGPointMake(0, -64)];
                             } completion:nil];

它将保持self.collectionView.contentOffset不变。


这是一个很好的解决方案,当覆盖targetContentOffsetForProposedContentOffset:不是理想的时候。对于我来说,我正在从自定义布局过渡到流式布局,并且我不想为此子类化流式布局。谢谢! - Richard Venable
self.someLayout 是什么? - ramesh bhuja
请查看UICollectionView的API,获取您想要过渡到的自定义布局的UICollectionViewLayout实例。 - nikolsky
它能够工作,但我不会说它运行得很顺畅(例如,从4x4切换到2x2网格时会出现故障)。 - Evgeny Karkan

3

如果你只是希望在布局转换时内容偏移量不变,可以创建一个自定义布局并覆盖几个方法来跟踪旧的contentOffset并重用它:

@interface CustomLayout ()

@property (nonatomic) NSValue *previousContentOffset;

@end

@implementation CustomLayout

- (CGPoint)targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset
{
    CGPoint previousContentOffset = [self.previousContentOffset CGPointValue];
    CGPoint superContentOffset = [super targetContentOffsetForProposedContentOffset:proposedContentOffset];
    return self.previousContentOffset != nil ? previousContentOffset : superContentOffset ;
}

- (void)prepareForTransitionFromLayout:(UICollectionViewLayout *)oldLayout
{
    self.previousContentOffset = [NSValue valueWithCGPoint:self.collectionView.contentOffset];
    return [super prepareForTransitionFromLayout:oldLayout];
}

- (void)finalizeLayoutTransition
{
    self.previousContentOffset = nil;
    return [super finalizeLayoutTransition];
}
@end

这只是在 prepareForTransitionFromLayout 中保存布局转换前的先前内容偏移量,覆盖 targetContentOffsetForProposedContentOffset 中的新内容偏移量,然后在 finalizeLayoutTransition 中清除它。非常简单明了。


我的天啊,还有targetContentOffset#forProposedContentOffset#withScrollingVelocity - Fattie

2
如果有助于增加经验,我遇到了这个问题,无论我的内容大小如何,无论我是否设置了内容插入或任何其他明显的因素。所以我的解决方法有些激进。首先,我子类化了UICollectionView并添加了对抗不适当的内容偏移设置的内容:
- (void)setContentOffset:(CGPoint)contentOffset animated:(BOOL)animated
{
    if(_declineContentOffset) return;
    [super setContentOffset:contentOffset];
}

- (void)setContentOffset:(CGPoint)contentOffset
{
    if(_declineContentOffset) return;
    [super setContentOffset:contentOffset];
}

- (void)setCollectionViewLayout:(UICollectionViewLayout *)layout animated:(BOOL)animated
{
    _declineContentOffset ++;
    [super setCollectionViewLayout:layout animated:animated];
    _declineContentOffset --;
}

- (void)setContentSize:(CGSize)contentSize
{
    _declineContentOffset ++;
    [super setContentSize:contentSize];
    _declineContentOffset --;
}

我并不为此感到骄傲,但唯一可行的解决方案似乎是彻底拒绝集合视图设置其自身内容偏移量的尝试,这是由于对setCollectionViewLayout:animated:的调用所导致的。根据经验,这种更改似乎直接发生在立即调用中,这显然不能由接口或文档保证,但从核心动画的角度来看是有道理的,因此我对这个假设可能只有50%的不舒服感。
然而还有第二个问题:当一个新的集合视图布局出现时,UICollectionView会给那些保持原地的视图添加一个小的跳跃效果——将它们向下推约240个点,然后再将它们动画回到原来的位置。我不清楚原因,但我修改了我的代码来处理它,通过切断已添加到任何未移动的单元格上的CAAnimation来实现。
- (void)setCollectionViewLayout:(UICollectionViewLayout *)layout animated:(BOOL)animated
{
    // collect up the positions of all existing subviews
    NSMutableDictionary *positionsByViews = [NSMutableDictionary dictionary];
    for(UIView *view in [self subviews])
    {
        positionsByViews[[NSValue valueWithNonretainedObject:view]] = [NSValue valueWithCGPoint:[[view layer] position]];
    }

    // apply the new layout, declining to allow the content offset to change
    _declineContentOffset ++;
    [super setCollectionViewLayout:layout animated:animated];
    _declineContentOffset --;

    // run through the subviews again...
    for(UIView *view in [self subviews])
    {
        // if UIKit has inexplicably applied animations to these views to move them back to where
        // they were in the first place, remove those animations
        CABasicAnimation *positionAnimation = (CABasicAnimation *)[[view layer] animationForKey:@"position"];
        NSValue *sourceValue = positionsByViews[[NSValue valueWithNonretainedObject:view]];
        if([positionAnimation isKindOfClass:[CABasicAnimation class]] && sourceValue)
        {
            NSValue *targetValue = [NSValue valueWithCGPoint:[[view layer] position]];

            if([targetValue isEqualToValue:sourceValue])
                [[view layer] removeAnimationForKey:@"position"];
        }
    }
}

这似乎不会阻止实际移动的视图,也不会导致它们移动不正确(就像它们期望周围所有内容都向下约240个点,并与它们一起以正确的位置动画显示)。

因此,这是我的当前解决方案。


Tommy,谢谢分享。我刚刚添加了一个解决方案到我的问题里,你可能会觉得有用。 - Timothy Moose

0

我已经花了大约两个星期的时间尝试让各种布局之间平稳地过渡。我发现在iOS 10.2中覆盖建议的偏移量是有效的,但在此之前的版本中仍然存在问题。使我的情况稍微恶化的是,由于滚动,我需要过渡到另一个布局,因此视图同时在滚动和过渡。

Tommy的答案是在10.2之前的版本中唯一有效的方法。我现在正在做以下事情。

class HackedCollectionView: UICollectionView {

    var ignoreContentOffsetChanges = false

    override func setContentOffset(_ contentOffset: CGPoint, animated: Bool) {
        guard ignoreContentOffsetChanges == false else { return }
        super.setContentOffset(contentOffset, animated: animated)
    }

    override var contentOffset: CGPoint {
        get {
            return super.contentOffset
        }
        set {
            guard ignoreContentOffsetChanges == false else { return }
            super.contentOffset = newValue
        }
    }

    override func setCollectionViewLayout(_ layout: UICollectionViewLayout, animated: Bool) {
        guard ignoreContentOffsetChanges == false else { return }
        super.setCollectionViewLayout(layout, animated: animated)
    }

    override var contentSize: CGSize {
        get {
            return super.contentSize
        }
        set {
            guard ignoreContentOffsetChanges == false else { return }
            super.contentSize = newValue
        }
    }

}

然后当我设置布局时,我会这样做...

let theContentOffsetIActuallyWant = CGPoint(x: 0, y: 100)
UIView.animate(withDuration: animationDuration,
               delay: 0, options: animationOptions,
               animations: {
                collectionView.setCollectionViewLayout(layout, animated: true, completion: { completed in
                    // I'm also doing something in my layout, but this may be redundant now
                    layout.overriddenContentOffset = nil
                })
                collectionView.ignoreContentOffsetChanges = true
}, completion: { _ in
    collectionView.ignoreContentOffsetChanges = false
    collectionView.setContentOffset(theContentOffsetIActuallyWant, animated: false)
})

警告:实际上存在 targetContentOffset#forProposedContentOffset#withScrollingVelocity - Fattie

-3

这个终于对我起作用了(Swift 3)

self.collectionView.collectionViewLayout = UICollectionViewFlowLayout()
self.collectionView.setContentOffset(CGPoint(x: 0, y: -118), animated: true)

1
“y: -118” 这个神奇数字的意义是什么?这个解决方案似乎只适用于特定情况,因此并不是很有帮助。 - Jordan

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接