在UICollectionView上动态设置布局会导致莫名其妙的contentOffset更改


70

根据Apple的文档(并在WWDC 2012上大肆宣传),可以UICollectionView动态设置布局,甚至为更改设置动画:

通常,在创建集合视图时可以指定布局对象,但是也可以动态更改集合视图的布局。布局对象存储在collectionViewLayout属性中。设置此属性可直接更新布局,而无需设置更改动画。如果要对更改进行动画处理,则必须改为调用setCollectionViewLayout:animated:方法。

但是,实际上,我发现对UICollectionView进行了莫名其妙甚至无效的更改contentOffset,导致单元无法正确移动,从而使该功能实际上无法使用。为了说明问题,我将以下示例代码放在一起,可以将其附加到放置在情节提要中的默认集合视图控制器上:

#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

控制器设置默认值UICollectionViewFlowLayoutviewDidLoad并在屏幕上显示单个单元格。选择单元格后,控制器将创建另一个默认值,UICollectionViewFlowLayout并使用animated:YES标志在收集视图上进行设置。预期的行为是该单元格不移动。但是,实际的行为是该单元格在屏幕外滚动,这时甚至不可能将其滚动到屏幕上。

查看控制台日志,发现contentOffset发生了莫名其妙的更改(在我的项目中,从(0,0)更改为(0,205))。我针对非动画案例发布了解决方案i.e. animated:NO),但是由于我需要动画,因此我非常想知道是否有人针对动画案例有解决方案或解决方法。

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


终于解决了。 @Isaacliu提供了正确的答案。我放入了现代Swift版本。
Fattie

Answers:


33

在这几天里,我一直在梳理头发,为我的情况找到了解决方案,可能会有所帮助。就我而言,我的照片布局像ipad上的“照片”应用程序一样会折叠。它显示相册,照片彼此叠放,当您点击相册时,它将展开照片。所以我有两个独立的UICollectionViewLayouts,并在它们之间进行切换,[self.collectionView setCollectionViewLayout:myLayout animated:YES]我遇到了确切的问题,就是动画之前的单元格跳跃并意识到是contentOffset。我使用尝试了所有操作,contentOffset但在动画制作过程中仍然跳了起来。泰勒的上述解决方案有效,但仍使动画混乱。

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

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

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

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

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

我对此进行了研究,得出的结论是,仅当contentSize与collectionView的大小完全匹配时,您的解决方法才是可靠的。因此,我认为它适用于非滚动集合视图。
Timothy Moose 2013年

几乎是正确的,您似乎唯一需要确保collectionViewContentSize方法返回的大小至少与收集视图一样大。我的布局是垂直滚动的,因此我只是确保返回的大小是正确的宽度,然后是所计算的大小的最大值,我的布局和集合视图的高度。
rougeExciter

5
如果您从UICollectionViewFlowLayout继承,则非常简单:-(CGSize)collectionViewContentSize {//解决方法CGSize superSize = [super collectionViewContentSize]; CGRect框架= self.collectionView.frame; 返回CGSizeMake(fmaxf(superSize.width,CGRectGetWidth(frame)),fmaxf(superSize.height,CGRectGetHeight(frame))); }
manicaesar

1
manicaesar,那把戏。应该将其添加到某个答案中,这样它才不会在评论中丢失。我在答案中添加了它。
Joris Mans

5
看看布局子类中的targetContentOffsetForProposedContentOffset:和targetContentOffsetForProposedContentOffset:withScrollingVelocity:。
ıɾuǝʞ

38

UICollectionViewLayout包含可重写的方法targetContentOffsetForProposedContentOffset:,该方法使您可以在更改布局时提供适当的内容偏移,并且可以正确设置动画。在iOS 7.0及更高版本中可用


希望我能再投票几次。对于涉及此问题的某些情况子集(例如,对于我来说,我想保持当前状态contentOffset),这是理想的解决方案。谢谢@ cdemiris99。
sam-w

1
这解决了我眼前的问题,但我想知道为什么吗?感觉我不必这样做。可以更好地了解这里的交易,并会编写一个错误。
bpapa 2015年

您是个天才,可以完美解决问题!
Blane Townsend 2015年

1
override func targetContentOffsetForProposedContentOffset(proposedContentOffset: CGPoint) -> CGPoint { if let collectionView = self.collectionView { let currentContentOffset = collectionView.contentOffset if currentContentOffset.y < proposedContentOffset.y { return currentContentOffset } } return proposedContentOffset } 解决了问题。感谢您的提示。
Massmaker 2015年

7

这个问题也困扰着我,这似乎是过渡代码中的错误。据我所知,它试图将注意力集中在最接近过渡前视图布局中心的单元格上。但是,如果在过渡前视图的中心没有碰到一个单元,则它仍会尝试将单元在过渡后的位置居中。如果将alwaysBounceVertical / Horizo​​ntal设置为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。如果您单击第一个单元格,则视图向下滚动几行。然后,如果您点击第一个可见的单元格,则视图将滚动回到顶部。如果您然后再次点击第一个单元格,它将保持静止(正确的行为)。如果您在底部的行上点击一个单元格,它将反弹。将视图向下滚动几行,点击任意一行,视图将滚动回到顶部。
蒂莫西·穆斯

1
我发现在scrollToItemAtIndexPath:中使用animation:NO:调用使事情看起来更好。
honus

2
看起来他们已经添加了用于控制iOS7中此行为的API。
tyler

@tyler,您指的是哪些新的iOS7 API?
brainjam

12
检出UICollectionViewLayout方法-(CGPoint)targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset。苹果对我的建议是将我要关注的单元格的目标索引填充到布局中(在我的自定义布局扩展中暴露一个NSIndexPath属性),然后使用此信息返回适当的targetContentOffset。这个解决方案对我有用。
tyler

7

跳进我自己问题的最新答案。

TLLayoutTransitioning库提供了重新布置任务iOS7s互动转变的API,这个问题很好的解决方案做非交互式,版面布局的转变。它有效地提供了替代方案setCollectionViewLayout,解决了内容偏移问题并添加了以下功能:

  1. 动画时长
  2. 30余条缓动曲线(由Warren Moore的AHEasing库提供
  3. 多种内容偏移模式

可以将自定义缓动曲线定义为AHEasingFunction函数。可以使用“最小”,“中心”,“顶部”,“左”,“底部”或“右”放置选项,根据一个或多个索引路径来指定最终的内容偏移量。

要了解我的意思,请尝试在“示例”工作区中运行“调整大小”演示,然后使用这些选项。

用法是这样的。首先,配置您的视图控制器以返回的实例TLTransitionLayout

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

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

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回调,该回调以指定的持续时间和缓动函数来驱动过渡进度。

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

6

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的代码片段中的FWIW finalizeLayoutTransition。实际上,从逻辑上讲这没有必要。

事实是,除非Apple改变其工作方式,否则每次在集合视图布局之间创建动画时,都必须这样做。这就是生活!


1
是的,我之前的答案不正确。感谢您指出!学习永远不会太晚(:
iWheelBuy19年

1
@iWheelBuy,十年来只有2个人找到了正确的技术:)您就是其中之一。您的答案有一个小错误,我已更正并更新了语法。非常感谢您的解决方案!!!!!!!!!
Fattie

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:不理想时,这是一个很好的解决方案。对我来说,我正在从自定义布局过渡到流布局,并且我不想为此细分流布局。谢谢!
理查德·韦纳布尔

什么是self.someLayout?
ramesh bhuja

您要转换为自定义布局的UICollectionViewLayout实例。看看UICollectionView的API
nikolsky

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:。从经验上看,这种改变似乎直接发生在立即调用中,这显然不是由界面或文档保证的,但是从Core Animation的角度来看是有意义的,因此我可能对此假设只有50%的不满意。

但是,还有第二个问题:UICollectionView现在向在新的集合视图布局中保留在同一位置的那些视图增加了一些跳转-将它们向下推大约240点,然后将其动画化回原始位置。我不清楚为什么,但是我还是修改了我的代码以通过处理CAAnimation实际上未移动的任何单元中添加的s来处理它:

- (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点并与它们建立正确的位置一样)。

所以这是我目前的解决方案。


汤米,谢谢分享。我只是为自己的问题添加了一个解决方案,您可能会觉得有用。
Timothy Moose 2014年

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

-2

终于对我有用了(Swift 3)

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

y: -118幻数的意义是什么?该解决方案似乎是针对特定情况的,因此并不是那么有用。
约旦·邦多
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.