Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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 I managed 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 how 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'd be interested in how to make this view 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.

like image 607
Robert J. Clegg Avatar asked May 09 '14 12:05

Robert J. Clegg


2 Answers

@jrturton's answer is helpful, however unless I'm missing something it is really overcomplicating something very simple. I'll start with the points we agree on...

Prevent interaction while changing layouts

First off, I agree with the approach of disabling user interaction at the start of the layout transition & reenabling at the end (in the completion block) using [[UIApplication sharedApplication] begin/endIgnoringInteractionEvents] - this is much better than trying cancel an in-progress transition animation & immediately begin the reverse transition from the current state.

Simplify the layout transition by using a single cell class

Also, I very much agree with the suggestion to use the same cell class for each layout. Register a single cell class in viewDidLoad, and simplify your collectionView:cellForItemAtIndexPath: method to just dequeue a cell and set its data:

- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath
{
    BBItemCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:cellID forIndexPath:indexPath];
    if ([self.searchArray count] > 0) {
        cell.backgroundColor = [UIColor whiteColor];
        cell.item = self.searchArray[indexPath.row];
    }
    return cell;
}

(Notice that the cell itself shouldn't (in all but exceptional cases) need to be aware of anything to do with what layout is currently in use, whether layouts are transitioning, or what the current transition progress is)

Then when you call setCollectionViewLayout:animated:completion: the collection view doesn't need to reload any new cells, it just sets up an animation block to change each cell's layout attributes (you don't need to call this method from inside an performBatchUpdates: block, nor do you need to invalidate the layout manually).

Animating the cell subviews

However as pointed out, you will notice that subviews of the cell jump immediately to their new layout's frames. The solution is to simply force immediate layout of the cells subviews when the layout attributes are updated:

- (void)applyLayoutAttributes:(UICollectionViewLayoutAttributes *)layoutAttributes
{
    [super applyLayoutAttributes:layoutAttributes];
    [self layoutIfNeeded];
}

(No need to create special layout attributes just for the transition)

Why does this work? When the collection view changes layouts, applyLayoutAttributes: is called for each cell as the collection view is setting up the animation block for that transition. But the layout of the cell's subviews is not done immediately - it is deferred to a later run loop - resulting in the actual subview layout changes not being incorporated into the animation block, so the subviews jump to their final positions immediately. Calling layoutIfNeeded means that we are telling the cell that we want the subview layout to happen immediately, so the layout is done within the animation block, and the subviews' frames are animated along with the cell itself.

It is true that using the standard setCollectionViewLayout:... API does restrict control of the animation timing. If you want to apply a custom easing animation curve then solutions like TLLayoutTransitioning demonstrate a handy way of taking advantage of interactive UICollectionViewTransitionLayout objects to take control of the animation timing. However, as long as only a linear animation of subviews is required I think most people will be satisfied with the default animation, especially given the one-line simplicity of implementing it.

For the record, I'm not keen on the lack of control of this animation myself, so implemented something similar to TLLayoutTransitioning. If this applies to you too, then please ignore my harsh reproval of @jrturton's otherwise great answer, and look into TLLayoutTransitioning or UICollectionViewTransitionLayouts implemented with timers :)

like image 198
Stuart Avatar answered Sep 19 '22 16:09

Stuart


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.

like image 21
jrturton Avatar answered Sep 21 '22 16:09

jrturton