Adoption Curve Dot Net

Creating a Paged Photo Gallery With a UICollectionView

| Comments

The “traditional” way of creating a swipeable gallery of images uses a UIScrollView and a series of views containing images which are placed in a line inside the scrollView. It’s a technique that works – but it’s fiddly to set up, and it doesn’t scale well. With more than a very few images in the gallery, you need to start implementing lazy loading to prevent the gallery gobbling up memory.

If you think of a gallery as being a UITableView laid on its side, then it’s pretty obvious that we could use something similar and take advantage of the lazy loading of cells that a table view offers. Prior to iOS6, sideways-scrolling tableViews were tricky to set up – but iOS6 came to the rescue with UICollectionView.

This is a worked example of setting up a full-screen paged image gallery using UICollectionView that lazily-loads images, and supports dynamic resizing of images during device rotation.

Setting up the project

Create a Single View Application project in Xcode. You should end up with an AppDelegate and a single view controller and xib file.

Wiring up the collection view

In the main view controller’s .h file, add the UICollectionViewDelegate and UICollectionViewDatasource protocols:

@interface CMFViewController : UIViewController <UICollectionViewDataSource, UICollectionViewDelegate>

Then switch to the .m file and create two new properties:

@interface CMFViewController ()
@property (nonatomic, weak) IBOutlet UICollectionView *collectionView;
@property (nonatomic, strong) NSArray *dataArray;
@end

In the main view controller’s ‘.xib’, drag and drop a UICollectionView object into the view, and then connect it to the collectionView property. You’ll also need to connect the UICollectionView object’s dataSource and delegate outlets to the File's Owner object.

Creating the data

For the purposes of this demo, I’ve assumed that there’s an Assets directory in the project’s folder structure, and that contains a series of images.

The first step is to load the names of the files in this directory into the dataArray property – I’ve done this with a seperate method:

-(void)loadImages {
    NSString *sourcePath = [[[NSBundle mainBundle] resourcePath] stringByAppendingPathComponent:@"Assets"];
    self.dataArray = [[NSFileManager defaultManager] contentsOfDirectoryAtPath:sourcePath error:NULL];
}

This method gets called in the view controller’s viewDidLoad method:

- (void)viewDidLoad {
    [super viewDidLoad];
    [self loadImages];
}

Feeding the collection view with data

There are three methods that need to be implemented to feed the collection view with data:

-(NSInteger)numberOfSectionsInCollectionView:(UICollectionView *)collectionView;
-(NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section
-(UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath;

Taking these in turn, the first is to tell the collection view how many sections it has. This being a simple example, there’s only one:

-(NSInteger)numberOfSectionsInCollectionView:(UICollectionView *)collectionView {
    return 1;
}

Next, the collection view needs to know how many items it’s going to be displaying in total – which is the number of images that live in the Assets directory:

-(NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section {
    return [self.dataArray count];
}

The last third method is where the real work takes place, and the collection view cells are created. Before we can do this, though, we need to go ahead and create the cells.

Creating the UICollectionViewCells

There’s a couple of ways of creating UICollectionViewCells – the nib-based route, or creating a custom subclass. The latter is the more flexible, so this is the route we’ll take here.

Create a new UICollectionViewCell subclass called CMFGalleryCell. This is going to need one public property declared in the .h file:

@property (nonatomic, strong) NSString *imageName;

and one public method:

-(void)updateCell;

Next we’re going to need a .xib file for the cell, so create a new that and give it a name – mine’s CMFGalleryCell.xib.

By default, the new view comes into life with a UIView inside it – we don’t need this, so delete it and replace it with a UICollectionViewCell object. The canned object is 50 points x 50 points, which isn’t what we need – change the dimensions to 320 x 548 (for a 4-inch device) or 320 x 440 (for a 3.5” device).

While you’re there, change the color of the background to white, and drop a UILabel to the center of the view. This label won’t show anything useful, but will help to show the effect of paging through the collection view when it runs for the first time.

Next, we need to tell this UICollectionViewCell that it’s actually an instance of our custom class. Select the object, and in the Identity inspector change the custom class field to CMFGalleryCell.

Once the object knows what it’s supposed to be, we need to update the CMFGalleryCell class so that it can extract the UICollectionViewCell from the nib file when the need arises. There’s several ways of doing this – here’s the way I tend to do it, in the initWithFrame: method:

- (id)initWithFrame:(CGRect)frame {
    self = [super initWithFrame:frame];
    if (self) {
        // Initialization code
        NSArray *arrayOfViews = [[NSBundle mainBundle] loadNibNamed:@"CMFGalleryCell" owner:self options:nil];

        if ([arrayOfViews count] < 1) {
            return nil;
        }

        if (![[arrayOfViews objectAtIndex:0] isKindOfClass:[UICollectionViewCell class]]) {
            return nil;
        }

        self = [arrayOfViews objectAtIndex:0];
    }

    return self;
}

Stepping through this, the first task is to call the superclass’s initWithFrame: method. Assuming that this succeeds, we can then grab the contents of the xib file into the arrayOfViews array. This should have the UICollectionViewCell object as the first item in the array – but if that isn’t the case, the two if statements will allow things to fail gracefully.

Assuming things are where they’re supposed to be, the penultimate line grabs the UICollectionViewCell from the arrayOfViews, and the last line returns it.

Feeding the collection view with cells

Now we’re ready to send the UICollectionView some cells to display. Switch back to the view controller, and import the custom cell class:

#import "CMFGallleryCell.h"

Now we’re in a position to register this class for use. This needs to be done before the collectionView is displayed, so I tend to stick this in a configureCollectionView method which is called from viewDidLoad::

- (void)viewDidLoad {
    [super viewDidLoad];

    [self loadImages];
    [self setupCollectionView];
}

The setupCollectionView method is going to expand as we go along, but for the moment it needs to register the cell subclass for use:

-(void)setupCollectionView {
    [self.collectionView registerClass:[CMFGalleryCell class] forCellWithReuseIdentifier:@"cellIdentifier"];
}

That’s pretty self-explanatory – it tells the collectionView that there’s a class called CMFGalleryCell that will be associated with the reuse identifier cellIdentifier

Now we can start to implement the method that does the heavy lifting of cell creation:

-(UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath {
    CMFGalleryCell *cell = (CMFGalleryCell *)[collectionView dequeueReusableCellWithReuseIdentifier:@"cellIdentifier" forIndexPath:indexPath];

    return cell;
}

A lot takes place behind the scenes here. Every time the collection view needs a cell, it will call the collectionView:cellForItemAtIndexPath: method on its dataSource, and expect a UICollectionViewCell object to be returned.

The method first attempts to dequeue an existing cell to save the memory overhead of creating a new one. If for any reason there isn’t a reusable cell available in the queue (for example, if it’s the first cell being created) then behind the scenes, the collection view’s dataSource object will create a new instance of one. As far as we’re concerned, we’re guaranteed to get a cell returned – either a shiny new one, or one that’s been used prevously and is ready for recycling.

Configuring the collection view at runtime

We’ve got to the point where there’s a collection view and collection view cells (albeit ones that don’t do anything yet). In order to get the cells displayed correctly, there’s a bit more configuration required.

This means expanding the existing setupCollectionView method:

-(void)setupCollectionView {
    [self.collectionView registerClass:[CVGGallleryCell class] forCellWithReuseIdentifier:@"cellIdentifier"];

    UICollectionViewFlowLayout *flowLayout = [[UICollectionViewFlowLayout alloc] init];
    [flowLayout setScrollDirection:UICollectionViewScrollDirectionHorizontal];
    [flowLayout setMinimumInteritemSpacing:0.0f];
    [flowLayout setMinimumLineSpacing:0.0f];
    [self.collectionView setPagingEnabled:YES];
    [self.collectionView setCollectionViewLayout:flowLayout];
}

In order to know how to display content, the collection view relies on a `UICollectionViewLayout` object which provides the necessary information to allow the collection view to draw the cells into the user interface.

The linear grid-based layout is sufficiently generic that Apple have provided a `UICollectionViewLayout` subclass called `UICollectionViewFlowLayout`.  Out of the box, this will draw lines of cells in a grid arrangement.  You don't need to understand too much about the detail at this stage, but these are the important characteristics that we need to implement in order to create the gallery view:

Creating the flow layout

The flow layout is created with

UICollectionViewFlowLayout *flowLayout = [[UICollectionViewFlowLayout alloc] init];

Configuring the scroll direction

Flow layouts can scroll in either vertical (up-and-down table view style) or horizontal (side-to-side gallery style) directions. We’re going to use horizontal:

[flowLayout setScrollDirection:UICollectionViewScrollDirectionHorizontal];

Setting the cell spacing

To get the seamless gallery effect, we need to setup the collection view to display the cells without any spacing:

[flowLayout setMinimumInteritemSpacing:0.0f];
[flowLayout setMinimumLineSpacing:0.0f];

Setting up paging

Getting each image to “snap” to the screen as it scrolls is a nice touch, and UICollectionView provides that functionality out of the box:

[self.collectionView setPagingEnabled:YES];

Setting the item size

The collection view needs be informed of the size of the item, which can be set statically or dynamically via a delegate protocol. If you’re only ever going to have one size of cell, then you can simple set the item size with:

[flowLayout setItemSize:CGSizeMake(320, 548)];

However, it can also be set dynamically through the UICollectionViewDelegateFlowLayout protocol. To use this method, first switch to the view controller’s header file and add the declaration for the protocol:

@interface CMFViewController : UIViewController <UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout>

Then switch back to the implementation file and add the collectionView:layout:sizeForItemAtIndexPath: method:

-(CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout *)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath {
    return CGSizeMake(320, 548);
}

Applying the layout to the collection view

Once the UICollectionViewFlowLayout is setup and configured, it needs to be applied to the collection view itself:

[self.collectionView setCollectionViewLayout:flowLayout];

At this point, if you build and run the project, you’ll see a working collection view with paging cells:

Configuring the cells at runtime

Functional as this is, it doesn’t yet look much like a gallery. To get the images requires a bit more work, firstly in the view controller:

Feeding the cells with data

Thinking back to the data that we’re working with, we’ve got an NSArray of NSStrings containing image filenames. We’re going to pass that filename to the cell, and then load the image into the cell.

Passing the filename to the cell can be done when the cell is dequeued (or created) in the collectionView:cellForItemAtIndexPath: method:

-(UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath {
    CMFGalleryCell *cell = (CMFGalleryCell *)[collectionView dequeueReusableCellWithReuseIdentifier:@"cellIdentifier" forIndexPath:indexPath];

    NSString *imageName = [self.dataArray objectAtIndex:indexPath.row];
    [cell setImageName:imageName];

    [cell updateCell];

    return cell;
}

Loading the image into the cell

We’ll do this in the cell’s updateCell method

-(void)updateCell {
    NSString *sourcePath = [[[NSBundle mainBundle] resourcePath] stringByAppendingPathComponent:@"Assets"];
    NSString *filename = [NSString stringWithFormat:@"%@/%@", sourcePath, self.imageName];

    UIImage *image = [UIImage imageWithContentsOfFile:filename];

    [self.imageView setContentMode:UIViewContentModeScaleAspectFit];
    [self.imageView setImage:image];
}

One final tweak – switch to the cell’s .xib file, remove the UILabel and change the background colour of the UICollectionViewCell to ‘default’.

Run the app again, and now you’ve got a paging gallery:

Handling device rotation

At the moment, if you rotate the simulator (or device) you’ll see an error in the console:

UICollectionGallery[26606:c07] the behavior of the UICollectionViewFlowLayout is not defined because:
UICollectionGallery[26606:c07] the item height must be less that the height of the UICollectionView minus the section insets top and bottom values.

That’s occuring because when the device rotates, the collection view cell is taller than the collection view itself. We can silence this error by invalidating the collectionViewLayout before the rotation starts:

-(void)willRotateToInterfaceOrientation:(UIInterfaceOrientation)toInterfaceOrientation duration:(NSTimeInterval)duration {
    [self.collectionView.collectionViewLayout invalidateLayout];
}

But now there’s a different problem – the collectionView’s offsets are all over the place if you rotate when displaying something other than the first image.

There’s a couple of steps involved in fixing this:

Making the collection view cell size dynamic

We want the collection view cell to be the same size as the collection view, regardless of whether the device is in portrait or landscape orientation. If we’d set the collection view cell size statically (in the setupCollectionView method) that would be tricky – but we future-proofed ourselves by making this a dynamic method.

The collection view cell size is controlled by the collectionView:layout:sizeForItemAtIndexPath: method – so we can update this to return whatever the current size of the collectionView is:

-(CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout *)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath {
    return self.collectionView.frame.size;
}

Forcing the collection view to update

If you run the app again, you’ll see that the error still occurs. That’s because having changed the size of the collection view cells, we need to force the collection view to redraw itself with the correct offset. We can do this while the device is rotating by hooking into the didRotateFromInterfaceOrientation: method and adjusting the contentOffset to move the collection view around.

First, though, we need to capture how far the collection view is currently offset before the rotation starts. Add a property to the class:

@property (nonatomic) int currentIndex;

And update the willRotateToInterfaceOrientation:duration: method:

CGPoint currentOffset = [self.collectionView contentOffset];
self.currentIndex = currentOffset.x / self.collectionView.frame.size.width;

This gets the current offset’s x value and divides this by the width of the collectionView to calculate how many by how many cells the collectionView is scrolled across.

Now it’s time to force the rotated collectionView in the right position. This has to be done after the rotation has taken place, so involves updating the didRotateFromInterfaceOrientation: method. This exploits the fact that UICollectionView inherits from UIScrollView and has a contentOffset property that can be updated:

-(void)didRotateFromInterfaceOrientation:(UIInterfaceOrientation)fromInterfaceOrientation {
    // Force realignment of cell being displayed
    CGSize currentSize = self.collectionView.bounds.size;
    float offset = self.currentIndex * currentSize.width;
    [self.collectionView setContentOffset:CGPointMake(offset, 0)];
}

This is simple enough – we get the size of the collectionView after it’s rotated; and then calculate how far along the newly-sized collectionView the content has to be scrolled to match.

If you run the project now, everything will fit into the right place on rotation, albeit with some nasty flickering as the content snaps into place. It would be nicer if that was hidden, so we can update the willRotateToInterfaceOrientation:duration: method to hide the collectionView before the rotation starts:

-(void)willRotateToInterfaceOrientation:(UIInterfaceOrientation)toInterfaceOrientation duration:(NSTimeInterval)duration {
    [self.collectionView setAlpha:0.0f];

    [self.collectionView.collectionViewLayout invalidateLayout];

    CGPoint currentOffset = [self.collectionView contentOffset];
    self.currentIndex = currentOffset.x / self.collectionView.frame.size.width;
}

And reveal it again once the rotation has completed:

-(void)didRotateFromInterfaceOrientation:(UIInterfaceOrientation)fromInterfaceOrientation {
    NSIndexPath *indexPath = [NSIndexPath indexPathForItem:self.currentIndex inSection:0];

    [self.collectionView scrollToItemAtIndexPath:indexPath atScrollPosition:UICollectionViewScrollPositionLeft animated:NO];

    [UIView animateWithDuration:0.125f animations:^{
        [self.collectionView setAlpha:1.0f];
    }];
}

I’ve added a slight fade-in effect which visually smooths the animation a bit.

Going further

Although this example is based on a full-screen gallery, there’s no reason why it can’t be adapted to fit into a pre-existing interface. It’s just a case of amending the sizes of the collection view and cells, and tweaking the paging increments accordingly.

Source code

A complete project with source code is downloadable from https://github.com/timd/UICollectionViewGallery.git

Comments