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.
@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...
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.
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).
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 UICollectionViewTransitionLayout
s implemented with timers :)
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:
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:
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.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With