Tander Tander - 2 months ago 24
Objective-C Question

Perfecting subview resizing in UICollectionView layout transition

I'm developing an app that has a

UICollectionView
- the collection view's job is to display data from a web service.

One feature of the app I am trying to implement is enabling the user to change the layout of this
UICollectionView
from a grid view to a table view.

I spent a lot of time trying to perfect this and get it working and I manage to get it to work.
However there are some issues. The transition between the two layout doesn't look good and sometimes it breaks between switching views and my app is left with a view in an unexpected state. That only happens if the user switches between grid and table view very quickly (Pressing the
changeLayoutButton
) continuously.

So, obviously there are some problems and I feel the code is a little.. fragile. I also need to fix the above mentioned issues.

I'll start off with out I implemented this view.

Implementation

Since I needed the two different cells (
grideCell
and
tableViewCell
) to show different things - I decided it would be better to subclass
UICollectionViewFlowLayout
since it does everything I need - all I need to do is change the cell sizes.

With that in mind I created two classes that subclassed
UICollectionViewFlowLayout


This is how those two classes look:

BBTradeFeedTableViewLayout.m

#import "BBTradeFeedTableViewLayout.h"

@implementation BBTradeFeedTableViewLayout

-(id)init
{
self = [super init];

if (self){

self.itemSize = CGSizeMake(320, 80);
self.minimumLineSpacing = 0.1f;
}

return self;
}

@end


BBTradeFeedGridViewLayout.m

#import "BBTradeFeedGridViewLayout.h"

@implementation BBTradeFeedGridViewLayout

-(id)init
{
self = [super init];

if (self){

self.itemSize = CGSizeMake(159, 200);
self.minimumInteritemSpacing = 2;
self.minimumLineSpacing = 3;
}
return self;
}

@end


very simple and as you can see - just changing the cell sizes.

Then in my
viewControllerA
class I implemented the
UICollectionView
like so:
Created the properties:

@property (strong, nonatomic) BBTradeFeedTableViewLayout *tableViewLayout;
@property (strong, nonatomic) BBTradeFeedGridViewLayout *grideLayout;


in
viewDidLoad


/* Register the cells that need to be loaded for the layouts used */
[self.tradeFeedCollectionView registerNib:[UINib nibWithNibName:@"BBItemTableViewCell" bundle:nil] forCellWithReuseIdentifier:@"TableItemCell"];
[self.tradeFeedCollectionView registerNib:[UINib nibWithNibName:@"BBItemGridViewCell" bundle:nil] forCellWithReuseIdentifier:@"GridItemCell"];


The user taps a button to change between layouts:

-(void)changeViewLayoutButtonPressed


I use a BOOL to determine which layout is currently active and based on that I make the switch with this code:

[self.collectionView performBatchUpdates:^{

[self.collectionView.collectionViewLayout invalidateLayout];
[self.collectionView setCollectionViewLayout:self.grideLayout animated:YES];

} completion:^(BOOL finished) {
}];


In
cellForItemAtIndexPath


I determine which cells I should use (grid or tableView) and the load the data - that code looks like this:

if (self.gridLayoutActive == NO){

self.switchToTableLayout = NO;
BBItemTableViewCell *tableItemCell = [collectionView dequeueReusableCellWithReuseIdentifier:tableCellIdentifier forIndexPath:indexPath];

if ([self.searchArray count] > 0){

self.switchToTableLayout = NO;

tableItemCell.gridView = NO;
tableItemCell.backgroundColor = [UIColor whiteColor];
tableItemCell.item = self.searchArray[indexPath.row];

}
return tableItemCell;
}else

{

BBItemTableViewCell *gridItemCell= [collectionView dequeueReusableCellWithReuseIdentifier:gridCellIdentifier forIndexPath:indexPath];

if ([self.searchArray count] > 0){

self.switchToTableLayout = YES;

gridItemCell.gridView = YES;
gridItemCell.backgroundColor = [UIColor whiteColor];
gridItemCell.item = self.searchArray[indexPath.row];

}

return gridItemCell;
}


Lastly in the two cell classes - I just use the data to set the image / text as I need.

Also in grid cell - the image is bigger and I remove text I don't want - which was the primary reason for uses two cells.

I would really appreciate some help on how to fix my current issues and improve this view to look a little more fluid and less.. buggy in the UI. The look I am going for is just like eBays iOS app - they switch between three different views. I just need to switch between two different views.

Answer

Grid / table transitions aren't as easy as a trivial demo would have you believe. They work fine when you've got a single label in the middle of the cell and a solid background, but once you have any real content in there, it falls over. This is why:

  • You have no control over the timing and nature of the animation.
  • While the frames of the cells in the layout are animated from one value to the next, the cells themselves (particularly if you are using two separate cells) don't seem to perform internal layout for each step of the animation so it seems to "flick" from one layout to the next inside each cell - your grid cell looks wrong in table size, or vice versa.

There are many different solutions. It's hard to recommend anything specific without seeing your cell's contents, but I've had success with the following:

  • take control of the animation using techniques like those shown here. You could also check out Facebook Pop to get better control over the transition but I haven't looked into that in any detail.
  • use the same cell for both layouts. Within layoutSubviews, calculate a transition distance from one layout to the other and use this to fade out or in unused elements, and to calculate nice transitional frames for your other elements. This prevents a jarring switch from one cell class to the other.

That's the approach I used here to fairly good effect.

It's harder work that relying on resizing masks or Autolayout but it's the extra work that makes things look good.

As for the issue when the user can toggle between the layouts too quickly - just disable the button when the transition starts, and re- enable it when you're done.

As a more practical example, here's a sample of the layout change (some of it is omitted) from the app linked above. Note that interaction is disabled while the transition occurs, I am using the transition layout from the project linked above, and there is a completion handler:

-(void)toggleLayout:(UIButton*)sender
{
    [[UIApplication sharedApplication] beginIgnoringInteractionEvents];

    HMNNewsLayout newLayoutType = self.layoutType == HMNNewsLayoutTable ? HMNNewsLayoutGrid : HMNNewsLayoutTable;

    UICollectionViewLayout *newLayout = [HMNNewsCollectionViewController collectionViewLayoutForType:newLayoutType];

    HMNTransitionLayout *transitionLayout = (HMNTransitionLayout *)[self.collectionView transitionToCollectionViewLayout:newLayout duration:0.5 easing:QuarticEaseInOut completion:^(BOOL completed, BOOL finish)
            {
                [[NSUserDefaults standardUserDefaults] setInteger:newLayoutType forKey:HMNNewsLayoutTypeKey];
                self.layoutType = newLayoutType;
                sender.selected = !sender.selected;
                for (HMNNewsCell *cell in self.collectionView.visibleCells)
                {
                    cell.layoutType = newLayoutType;
                }
                [[UIApplication sharedApplication] endIgnoringInteractionEvents];
            }];

    [transitionLayout setUpdateLayoutAttributes:^UICollectionViewLayoutAttributes *(UICollectionViewLayoutAttributes *layoutAttributes, UICollectionViewLayoutAttributes *fromAttributes, UICollectionViewLayoutAttributes *toAttributes, CGFloat progress)
    {
        HMNTransitionLayoutAttributes *attributes = (HMNTransitionLayoutAttributes *)layoutAttributes;
        attributes.progress = progress;
        attributes.destinationLayoutType = newLayoutType;
        return attributes;
    }];
}

Inside the cell, which is the same cell for either layout, I have an image view and a label container. The label container holds all the labels and lays them out internally using auto layout. There are constant frame variables for the image view and the label container in each layout.

The layout attributes from the transition layout are a custom subclass which include a transition progress property, set in the update layout attributes block above. This is passed into the cell using the applyLayoutAttributes method (some other code omitted):

-(void)applyLayoutAttributes:(UICollectionViewLayoutAttributes *)layoutAttributes
{
    self.transitionProgress = 0;
    if ([layoutAttributes isKindOfClass:[HMNTransitionLayoutAttributes class]])
    {
        HMNTransitionLayoutAttributes *attributes = (HMNTransitionLayoutAttributes *)layoutAttributes;
        self.transitionProgress = attributes.progress;
    }
    [super applyLayoutAttributes:layoutAttributes];
}

layoutSubviews in the cell subclass does the hard work of interpolating between the two frames for the images and labels, if a transition is in progress:

-(void)layoutSubviews
{
    [super layoutSubviews];

    if (!self.transitionProgress)
    {
        switch (self.layoutType)
        {
            case HMNNewsLayoutTable:
                self.imageView.frame = imageViewTableFrame;
                self.labelContainer.frame = labelContainerTableFrame;
                break;
            case HMNNewsLayoutGrid:
                self.imageView.frame = imageViewGridFrame;
                self.labelContainer.frame = self.originalGridLabelFrame;
                break;
        }
    }
    else
    {
        CGRect fromImageFrame,toImageFrame,fromLabelFrame,toLabelFrame;
        if (self.layoutType == HMNNewsLayoutTable)
        {
            fromImageFrame = imageViewTableFrame;
            toImageFrame = imageViewGridFrame;
            fromLabelFrame = labelContainerTableFrame;
            toLabelFrame = self.originalGridLabelFrame;
        }
        else
        {
            fromImageFrame = imageViewGridFrame;
            toImageFrame = imageViewTableFrame;
            fromLabelFrame = self.originalGridLabelFrame;
            toLabelFrame = labelContainerTableFrame;
        }

        CGFloat from = 1.0 - self.transitionProgress;
        CGFloat to = self.transitionProgress;

        self.imageView.frame = (CGRect)
        {
            .origin.x = from * fromImageFrame.origin.x + to * toImageFrame.origin.x,
            .origin.y = from * fromImageFrame.origin.y + to * toImageFrame.origin.y,
            .size.width = from * fromImageFrame.size.width + to * toImageFrame.size.width,
            .size.height = from * fromImageFrame.size.height + to * toImageFrame.size.height
        };

        self.labelContainer.frame = (CGRect)
        {
            .origin.x = from * fromLabelFrame.origin.x + to * toLabelFrame.origin.x,
            .origin.y = from * fromLabelFrame.origin.y + to * toLabelFrame.origin.y,
            .size.width = from * fromLabelFrame.size.width + to * toLabelFrame.size.width,
            .size.height = from * fromLabelFrame.size.height + to * toLabelFrame.size.height
        };
    }
    self.headlineLabel.preferredMaxLayoutWidth = self.labelContainer.frame.size.width;
}

And that's about it. Basically you need a way of telling the cell how far through the transition it is, which you need the layout transitioning library (or, as I say, Facebook pop might do this) for, and then you need to make sure you get nice values for layout when transitioning between the two.