在 UICollectionView 上动态设置布局会导致无法解释的 contentOffset 更改

根据苹果公司的文档(并在2012年的 WWDC 大会上吹捧) ,可以动态设置 UICollectionView的布局,甚至动画化更改:

通常在创建集合视图时指定布局对象,但也可以动态更改集合视图的布局。布局对象存储在 CollectionViewLayout 属性中。设置此属性直接立即更新布局,而不使更改具有动画效果。如果希望对更改进行动画处理,则必须调用 setCollectionViewLayout: animed: 方法。

然而,在实践中,我发现 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) ,但因为我需要动画,我很有兴趣知道是否有人有一个解决方案或工作区的动画情况。

作为附带说明,我已经测试了自定义布局,并得到了相同的行为。

71152 次浏览

这个问题也困扰着我,它似乎是转换代码中的一个 bug。据我所知,它试图将焦点集中在最靠近过渡前视图布局中心的单元格上。但是,如果在视图的中心没有一个单元格,那么它仍然会尝试将单元格设置为中心。如果您将 always Bounce纵向/横向设置为 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];
}

这几天我一直在为这件事烦恼,现在我找到了一个可能有帮助的解决办法。 在我的情况下,我有一个崩溃的照片布局像在 ipad 上的照片应用程序。它显示的相册上的照片互相顶部,当您点击一个相册,它扩展的照片。所以我有两个独立的 UICollectionViewLayouts,我用 [self.collectionView setCollectionViewLayout:myLayout animated:YES]在它们之间切换,我遇到了动画之前单元格跳动的问题,我意识到这是 contentOffset。我用 contentOffset尝试了所有的方法,但是它仍然在动画中跳跃。泰勒上面的解决方案起作用了,但是仍然扰乱了动画。

然后我注意到,只有当屏幕上只有几张专辑时,才会出现这种情况,而且这些专辑还不足以填满整个屏幕。我的布局覆盖 -(CGSize)collectionViewContentSize的建议。当只有几个相册时,集合视图内容大小小于视图内容大小。这导致当我在集合布局之间切换时发生跳转。

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

这不是一个真正的解决方案,但是它现在工作得很好。我会尝试将您的集合视图的 contentSize设置为至少它的包含视图的长度。

编辑: 如果你继承了 UICollectionViewFlowLayout,Manicesar 添加了一个简单的变通方法:

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

如果这有助于增加经验的话: 无论我的内容大小如何,无论我是否设置了内容插入,或任何其他明显的因素,我都会持续遇到这个问题。所以我的变通方法有点过激了。首先,我对 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点,并与它们一起动画到正确的位置)。

这就是我目前的解决方案。

对我自己的问题做了一个最后的回答。

转换库为这个问题提供了一个很好的解决方案,它将 iOS7的交互式转换 API 重新分配到非交互式的布局到布局转换。它有效地提供了 setCollectionViewLayout的替代品,解决了内容抵消问题,并增加了几个特点:

  1. 动画持续时间
  2. 30 + 缓和曲线(由沃伦 · 摩尔的 AHEING 图书馆提供)
  3. 多种内容偏移模式

定制的宽松曲线可以定义为 AHEasingFunction函数。最终的内容偏移量可以根据一个或多个索引路径指定,包括最小、中心、顶部、左边、底部或右边的放置选项。

为了理解我的意思,请尝试在示例工作区中运行 调整演示的大小并使用这些选项。

首先,将视图控制器配置为返回 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;

UICollectionViewLayout包含可重写的方法 targetContentOffsetForProposedContentOffset:,它允许您在布局更改期间提供适当的内容偏移量,这将正确地动画。这可以在 iOS 7.0及以上版本中找到

放松。

在同一动画块中动画新布局和 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恒定。

如果你只是简单地寻找内容偏移量,以便在从布局过渡时不改变,你可以创建一个自定义布局并覆盖一些方法来跟踪旧的 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中清除它。很简单

我可能已经花了大约两个星期的时间,试图让各种布局在彼此之间顺利过渡。我发现在 iOS 10.2中重写提议的偏移量是可行的,但是在之前的版本中我仍然会遇到这个问题。使我的情况更糟糕的是,我需要转换到另一个布局作为一个滚动的结果,所以视图是同时滚动和转换。

汤米的回答是在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)
})

这最终为我工作(斯威夫特3)

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

2019年实际解决方案

假设您有许多用于“ Cars”视图的布局。

假设你有三个。

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 的代码片段中 FWIW,增加了 finalizeLayoutTransition,事实上这在逻辑上是没有必要的。

事实上,除非苹果改变它的工作方式,否则每次在集合视图布局之间动画时,你都必须这样做。这就是生活!