70

According to Apple's documentation (and touted at WWDC 2012), it is possible to set the layout on UICollectionView dynamically and even animate the changes:

You normally specify a layout object when creating a collection view but you can also change the layout of a collection view dynamically. The layout object is stored in the collectionViewLayout property. Setting this property directly updates the layout immediately, without animating the changes. If you want to animate the changes, you must call the setCollectionViewLayout:animated: method instead.

However, in practice, I've found that UICollectionView makes inexplicable and even invalid changes to the contentOffset, causing cells to move incorrectly, making the feature virtually unusable. To illustrate the problem, I put together the following sample code that can be attached to a default collection view controller dropped into a storyboard:

#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

The controller sets a default UICollectionViewFlowLayout in viewDidLoad and displays a single cell on-screen. When the cells is selected, the controller creates another default UICollectionViewFlowLayout and sets it on the collection view with the animated:YES flag. The expected behavior is that the cell does not move. The actual behavior, however, is that the cell scroll off-screen, at which point it is not even possible to scroll the cell back on-screen.

Looking at the console log reveals that the contentOffset has inexplicably changed (in my project, from (0, 0) to (0, 205)). I posted a solution for the solution for the non-animated case (i.e. animated:NO), but since I need animation, I'm very interested to know if anyone has a solution or workaround for the animated case.

As a side-note, I've tested custom layouts and get the same behavior.

Community
  • 1
  • 1
Timothy Moose
  • 9,773
  • 3
  • 31
  • 44
  • **FINALLY SOLVED.** @Isaacliu has given the correct answer. I've put in the modern Swift version. – Fattie Sep 26 '19 at 02:38

10 Answers10

38

UICollectionViewLayout contains the overridable method targetContentOffsetForProposedContentOffset: which allows you to provide the proper content offset during a change of layout, and this will animate correctly. This is available in iOS 7.0 and above

epologee
  • 10,851
  • 9
  • 64
  • 102
cdemiris99
  • 587
  • 4
  • 6
  • Wish I could upvote this a few more times. For a certain subset of cases involving this issue (e.g. for me, where I want to maintain the current `contentOffset`), this is the perfect solution. Thanks @cdemiris99. – sam-w Sep 08 '14 at 04:52
  • 1
    This fixes my immediate issue, but I'm wondering why? Feels like I shouldn't have to do this. Going to get a better understanding of what the deal is here and will write a bug. – bpapa Apr 24 '15 at 16:08
  • You are a genius this fixes the issue perfectly! – Blane Townsend Jun 23 '15 at 19:09
  • 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 }` Fixed the problem. Thanks for a hint. – Massmaker Jul 15 '15 at 08:30
33

I have been pulling my hair out over this for days and have found a solution for my situation that may help. In my case I have a collapsing photo layout like in the photos app on the ipad. It shows albums with the photos on top of each other and when you tap an album it expands the photos. So what I have is two separate UICollectionViewLayouts and am toggling between them with [self.collectionView setCollectionViewLayout:myLayout animated:YES] I was having your exact problem with the cells jumping before animation and realized it was the contentOffset. I tried everything with the contentOffset but it still jumped during animation. tyler's solution above worked but it was still messing with the animation.

Then I noticed that it happens only when there were a few albums on the screen, not enough to fill the screen. My layout overrides -(CGSize)collectionViewContentSize as recommended. When there are only a few albums the collection view content size is less than the views content size. That's causing the jump when I toggle between the collection layouts.

So I set a property on my layouts called minHeight and set it to the collection views parent's height. Then I check the height before I return in -(CGSize)collectionViewContentSize I ensure the height is >= the minimum height.

Not a true solution but it's working fine now. I would try setting the contentSize of your collection view to be at least the length of it's containing view.

edit: Manicaesar added an easy workaround if you inherit from 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)));
}
Joris Mans
  • 5,371
  • 5
  • 35
  • 59
tassinari
  • 2,303
  • 2
  • 22
  • 30
  • I played with this some and came to the conclusion that your workaround is reliable only when the contentSize exactly matches the collectionView's size. So I think it works for non-scrolling collection views. – Timothy Moose Apr 12 '13 at 15:25
  • It's almost correct, the only thing you seem to need to ensure that the collectionViewContentSize method returns a size that is at least as big as the collection view. My layout is vertically scrolling so I simply ensured that I return a size that is the right width, followed by the max of the calculated size by my layout and the height of the collection view. – rougeExciter Apr 22 '13 at 12:08
  • 5
    In case you inherit from UICollectionViewFlowLayout it is as simple as: - (CGSize)collectionViewContentSize { //Workaround CGSize superSize = [super collectionViewContentSize]; CGRect frame = self.collectionView.frame; return CGSizeMake(fmaxf(superSize.width, CGRectGetWidth(frame)), fmaxf(superSize.height, CGRectGetHeight(frame))); } – manicaesar Jul 18 '13 at 10:01
  • 1
    manicaesar, that one does the trick. It should be added to some answer so it does not get lost in the comments. I added it in the answer. – Joris Mans Jul 24 '13 at 11:06
  • manicaesar's solution works perfectly when there are a few items in the collection. Perfect! – zontar Jul 28 '13 at 09:42
  • 5
    Have a look to targetContentOffsetForProposedContentOffset: and targetContentOffsetForProposedContentOffset:withScrollingVelocity: in your layout subclass. – ıɾuǝʞ Apr 23 '14 at 15:43
  • **anyone googling here, there is now finally a proper solution, 2019** scroll down to Isaac's answer or my Swift version – Fattie Sep 26 '19 at 02:53
7

This issue bit me as well and it seems to be a bug in the transition code. From what I can tell it tries to focus on the cell that was closest to the center of the pre-transition view layout. However, if there doesn't happen to be a cell at the center of the view pre-transition then it still tries to center where the cell would be post-transition. This is very clear if you set alwaysBounceVertical/Horizontal to YES, load the view with a single cell and then perform a layout transition.

I was able to get around this by explicitly telling the collection to focus on a specific cell (the first cell visible cell, in this example) after triggering the layout update.

[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];
}
tyler
  • 2,665
  • 1
  • 19
  • 28
  • I tried this workaround in my sample code and it was very close. The cell only bounced slightly. However, it didn't work as well in other scenarios. For example, change `numberOfItemsInSection:` to return 100. If you tap the first cell, the view scrolls down by several rows. Then if you tap the first visible cell, the view scrolls back to the top. If you then tap the first cell again, it remains stationary (correct behavior). If you tap a cell on the bottom row, it bounces. Scroll the view down by several rows, tap any row and the view scrolls back to the top. – Timothy Moose Dec 29 '12 at 21:00
  • 1
    I found using animated:NO in the scrollToItemAtIndexPath: call made things look better. – honus Jul 02 '13 at 08:34
  • 2
    Looks like they've added APIs for controlling this behavior in iOS7. – tyler Jul 02 '13 at 22:51
  • @tyler, which new iOS7 APIs are you referring to? – brainjam Oct 12 '13 at 00:06
  • 12
    Checkout the UICollectionViewLayout method - (CGPoint)targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset. Apples suggestion to me was to stuff the target index for the cell I wanted to focus on into the layout (expose an NSIndexPath prop on my custom layout extension) and then use this info to return the appropriate targetContentOffset. This solution worked for me. – tyler Oct 14 '13 at 17:11
  • @tyler how did you use the indexPath to calculate the targetContentOffset? Thanks – Salman Hasrat Khan Nov 25 '14 at 06:38
  • @SalmanHasratKhan My case is simple because the cells are uniform size and stacked vertically which means I can just multiply the indexPath item by the height of the cells, add the margins and insets and I'm good to go. Layouts that are more complex (dynamic sizes and multiple rows) are going to need to maintain these metrics somewhere or calculate them on the fly. – tyler Nov 26 '14 at 19:12
7

Jumping in with a late answer to my own question.

The TLLayoutTransitioning library provides a great solution to this problem by re-tasking iOS7s interactive transitioning APIs to do non-interactive, layout to layout transitions. It effectively provides an alternative to setCollectionViewLayout, solving the content offset issue and adding several features:

  1. Animation duration
  2. 30+ easing curves (courtesy of Warren Moore's AHEasing library)
  3. Multiple content offset modes

Custom easing curves can be defined as AHEasingFunction functions. The final content offset can be specified in terms of one or more index paths with Minimal, Center, Top, Left, Bottom or Right placement options.

To see what I mean, try running the Resize demo in the Examples workspace and playing around with the options.

The usage is like this. First, configure your view controller to return an instance of TLTransitionLayout:

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

Then, instead of calling setCollectionViewLayout, call transitionToCollectionViewLayout:toLayout defined in the UICollectionView-TLLayoutTransitioning category:

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

This call initiates an interactive transition and, internally, a CADisplayLink callback that drives the transition progress with the specified duration and easing function.

The next step is to specify a final content offset. You can specify any arbitrary value, but the toContentOffsetForLayout method defined in UICollectionView-TLLayoutTransitioning provides an elegant way to calculate content offsets relative to one or more index paths. For example, in order to have a specific cell to end up as close to the center of the collection view as possible, make the following call immediately after 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;
Timothy Moose
  • 9,773
  • 3
  • 31
  • 44
  • I was having some major animation issues with supplementary views and then I found this. Thank you very much! – Morrowless Jun 03 '14 at 07:16
7

2019 actual solution

Say you have a number of layouts for your "Cars" view.

Let's say you have three.

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

It will jump when you animate between layouts.

It's just an undeniable mistake by Apple. It jumps when you animate, without question.

The fix is this:

You must have a global float, and, the following base class:

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

So here are the three layouts for your "Cars" screen:

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

That's it. It now works.


  1. Incredibly obscurely, you could have different "sets" of layouts (for Cars, Dogs, Houses, etc.), which could (conceivably) collide. For this reason, have a global and a base class as above for each "set".

  2. This was invented by passing user @Isaacliu, above, many years ago.

  3. A detail, FWIW in Isaacliu's code fragment, finalizeLayoutTransition is added. In fact it's not necessary logically.

The fact is, until Apple change how it works, every time you animate between collection view layouts, you do have to do this. That's life!

Fattie
  • 30,632
  • 54
  • 336
  • 607
  • 1
    Yeah, my previous answer was not correct. Thanks for pointing it out! It's never too late to learn (: – iWheelBuy Sep 26 '19 at 06:48
  • 1
    @iWheelBuy , there were only 2 humans in a decade who figured out the correct technique :) You were one of them. There was a small mistake in your answer, I just corrected it and updated the syntax. Thanks so much for the solution!!!!!!!!! – Fattie Sep 27 '19 at 13:46
5

Easy.

Animate your new layout and collectionView's contentOffset in the same animation block.

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

It will keep self.collectionView.contentOffset constant.

nikolsky
  • 195
  • 3
  • 7
  • This is a great solution for when overriding targetContentOffsetForProposedContentOffset: is not ideal. For me, I am transitioning from a custom layout to a Flow Layout, and I don't want to subclass the flow layout for this. Thanks! – Richard Venable Aug 19 '15 at 23:52
  • what is the self.someLayout ? – ramesh bhuja Sep 07 '16 at 10:54
  • your instance of UICollectionViewLayout with your custom layout which you would like to transition to. Have a look at APIs of UICollectionView – nikolsky Sep 08 '16 at 06:24
3

If you are simply looking for the content offset to not change when transition from layouts, you can creating a custom layout and override a couple methods to keep track of the old contentOffset and reuse it:

@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

All this is doing is saving the previous content offset before the layout transition in prepareForTransitionFromLayout, overwriting the new content offset in targetContentOffsetForProposedContentOffset, and clearing it in finalizeLayoutTransition. Pretty straightforward

Isaac liu
  • 31
  • 1
  • My God, there is also `targetContentOffset#forProposedContentOffset#withScrollingVelocity` ! – Fattie Sep 26 '19 at 02:07
2

If it helps add to the body of experience: I encountered this problem persistently regardless of the size of my content, whether I had set a content inset, or any other obvious factor. So my workaround was somewhat drastic. First I subclassed UICollectionView and added to combat inappropriate content offset setting:

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

I'm not proud of it but the only workable solution seems to be completely to reject any attempt by the collection view to set its own content offset resulting from a call to setCollectionViewLayout:animated:. Empirically it looks like this change occurs directly in the immediate call, which obviously isn't guaranteed by the interface or the documentation but makes sense from a Core Animation point of view so I'm perhaps only 50% uncomfortable with the assumption.

However there was a second issue: UICollectionView was now adding a little jump to those views that were staying in the same place upon a new collection view layout — pushing them down about 240 points and then animating them back to the original position. I'm unclear why but I modified my code to deal with it nevertheless by severing the CAAnimations that had been added to any cells that, actually, weren't moving:

- (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"];
        }
    }
}

This appears not to inhibit views that actually do move, or to cause them to move incorrectly (as if they were expecting everything around them to be down about 240 points and to animate to the correct position with them).

So this is my current solution.

Tommy
  • 97,164
  • 12
  • 174
  • 193
0

I've probably spent about two weeks now trying to get various layout to transition between one another smoothly. I've found that override the proposed offset is working in iOS 10.2, but in version prior to that I still get the issue. The thing that makes my situation a bit worse is I need to transition into another layout as a result of a scroll, so the view is both scrolling and transitioning at the same time.

Tommy's answer was the only thing that worked for me in pre 10.2 versions. I'm doing the following thing now.

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

}

Then when I set the layout I do this...

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)
})
Mark Bridges
  • 7,606
  • 4
  • 43
  • 59
  • Alert there is actually `targetContentOffset#forProposedContentOffset#withScrollingVelocity` – Fattie Sep 26 '19 at 11:37
-2

This finally worked for me (Swift 3)

self.collectionView.collectionViewLayout = UICollectionViewFlowLayout()
self.collectionView.setContentOffset(CGPoint(x: 0, y: -118), animated: true)
Mark Hennings
  • 495
  • 6
  • 12
  • What's the significance of the `y: -118` magic number? This solution appears to be for a specific situation, and thus not all that helpful. – Jordan Oct 19 '17 at 18:29