How to make a bookshelf using UICollectionView

July 14, 2015 | 06:49 iOS Objective-C

The UICollectionView manages and displays a collection of data items. Generally it's like the UITableView but can support more layouts. Typically, we can use the UICollectionView to present a grid of images. This post will show you how to make an iOS bookshelf with UICollectionView widget.

BookShelf

Storyboard Stuff

Let's start with a new project. For this tutorial, it's ok for us to implement it in a simple single view application.

In the storyboard, we can drag a Collection View Controller in to the workspace, make it the enterance of our application. Then we need to create a BookshelfViewController class associated with the view. Don't forget to binding this class in the inspector of Collection View Controller in storybaord after creating the class.

Since most of the work will be done programmatically, now we are switching to the coding part.

Data Source

Before turning to the programmatical implementation, we need to construct some data source for our view controller. For this simple demo, we just made a very easy json file, which looks like this:

[{
"title":"The Art of Immersion",
"url":"http://bookcoverarchive.com/images/books/the_art_of_immersion.doublecol.jpg"
},
{
"title":"The Pale King",
"url":"http://bookcoverarchive.com/images/books/the_pale_king_1.thumb.jpg"
},
{
"title":"The Pale King",
"url":"http://bookcoverarchive.com/images/books/the_pale_king.thumb.jpg"
}]

In usual practice, you may need to fetch some data from a remote server and construct your json file, but for the purpose of this post, let's just skip this part and use this sample json file locally. You can download the sample file here, or get more covers on bookcoverarchive.com.

After getting the json file, we can write a method inside the BookshelfViewController.m, loading the json nodes into a local NSMutableArray called books.

- (BOOL)loadBooks {

    NSString * filePath =[[NSBundle mainBundle] pathForResource:@"sample" ofType:@"json"];
    NSData *data = [[NSData alloc] initWithContentsOfFile:filePath];
    NSError *error=nil;
    books = [NSJSONSerialization JSONObjectWithData:data options:NSJSONReadingMutableContainers error:&error];
    booksCount = (unsigned)[books count];

    if(error)
    {
        NSLog(@"Error reading file: %@",error.localizedDescription);
        return NO;
    }else {
        return YES;
    }

}

And don't forget to call this method with [self loadBooks]; inside the - (void)viewDidLoad; method.

So now we have already load the title and image URL into our array. Let's take a look at the remaining part of the our BookshelfViewController.m file. Since it is a subclass of UICollectionViewController, we will find some unimplemented methods in our .m file.

- (NSInteger)numberOfSectionsInCollectionView:(UICollectionView *)collectionView {
#warning Incomplete method implementation -- Return the number of sections
    return 0;
}


- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section {
#warning Incomplete method implementation -- Return the number of items in the section
    return 0;
}

- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath {
    UICollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:reuseIdentifier forIndexPath:indexPath];

    // Configure the cell

    return cell;
}

Basically, the most important configuration of the file is to override the cellForItemAtIndexPath method. But before coding that method, we need to tell the application how many sections are there and how many cells are there in every sections. Let's think of the number of books we want to put per section(one section is one level of our bookshelf), and make it a constant, like this:

static unsigned const NUMBER_PER_SECTION = 2;

And then modify our numberOfSectionsInCollectionView and numberOfItemsInSection methods:

- (NSInteger)numberOfSectionsInCollectionView:(UICollectionView *)collectionView {
    return booksCount / NUMBER_PER_SECTION;
}


- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section {
    if (section < booksCount / NUMBER_PER_SECTION) {
        return NUMBER_PER_SECTION;
    }else{
        return booksCount - NUMBER_PER_SECTION * section;
    }
    return 0;
}

Now we can start configuring the cells. So our cell will have two views, one label to show the title of the book, and below the label is the image of the cover of the book. When I created this bookshelf at the first time, I directly initialized two views inside the cell and tried to use [cell addSubview:lb]; to add them. This approach seems work at the first glance, but quickly you'll find the issue -- cells will keep adding the label view and image view when they are rendered.

Let's pause here and have a look at the rendering machanism of the UICollectionView. The view won't rendering the cell, which programmatically means to call the cellForItemAtIndexPath method until the cell appears on the screen. And if we scroll down the collection view and scroll up back, the cells at the top will be rendered again. Since we dynamically add some views inside the cells, these views will be added again, overlapping previous ones.

Therefore, we don't want things work that way. Instead of adding views directly inside the cell, we need to create our own cell class to manage its layout and subviews. So we can create a class called BookCell which is a subclass of the UICollectionViewCell class. Additionally, you can create the related nib file to work on its layout. The layout may look like this:

Book Cell nib

If you are doing that, don't forget to add these method in you BookCell.m file.

- (UIView *)viewFromNib
{
    Class class = [self class];
    NSString *nibName = NSStringFromClass(class);
    NSArray *nibViews = [[NSBundle mainBundle] loadNibNamed:nibName owner:self options:nil];
    UIView *view = [nibViews objectAtIndex:0];
    return view;
}


- (void)addSubviewFromNib
{
    UIView *view = [self viewFromNib];
    view.frame = self.bounds;
    [self addSubview:view];
}

- (id)initWithFrame:(CGRect)frame {
    self = [super initWithFrame:frame];
    if (self) {
        [self addSubviewFromNib];
    }
    return self;
}

Without these methods, you won't get the nib file and views inside loaded when you initialize the cell. Let's go back to configure the cell in the .m file of our view controller. We change the return type of cellForItemAtIndexPath method to our custom BookCell class, add the codes below to get it works.

- (BookCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath {
    long bookIndex = indexPath.section * NUMBER_PER_SECTION + indexPath.row;
    NSDictionary *book = [books objectAtIndex:bookIndex];
    NSString *title = [NSString stringWithFormat:@"%@",book[@"title"]];
    NSURL *imageURL = [NSURL URLWithString:book[@"url"]];
    NSData *data = [NSData dataWithContentsOfURL:imageURL];
    UIImage *image = [UIImage imageWithData:data];

    BookCell *cell = (BookCell *)[collectionView dequeueReusableCellWithReuseIdentifier:@"Book Cover Cell" forIndexPath:indexPath];
    cell.imageView.image = image;
    cell.bookTitleLabel.text = title;
    cell.bookTitleLabel.textColor = [UIColor whiteColor];
    cell.backgroundColor = nil;
    return cell;
}

Inside this method, with the first 6 lines, we create a NSDictionary to load the node of this cell from our books array, and get the title and image. And with the remaining part of the code, we init the cell and set the text and image to its subviews. Also, we need to load the nib file in our viewDidLoad method:

[self.collectionView registerNib:[UINib nibWithNibName:@"BookCell" bundle:nil] forCellWithReuseIdentifier:@"Book Cover Cell"];

We can set some insets for sections, which make the books arrange more nicely.

- (UIEdgeInsets)collectionView: (UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout insetForSectionAtIndex:(NSInteger)section {
    return UIEdgeInsetsMake(0, 60, 0, 60);
}

By now, our collection view may look like this:

Collection View w/o background

Adding UI elements

In this part, we need to add the UI parts of our view, make it looks like a bookshelf.

First we need to add a background of our bookshelf. I choose a background of wood blank, you may use any material you want. Make sure it has the correct resolution as your device. Meanwhile, for a better UI, make sure the background pattern looks continuous when being repeated vertically. Afterwards, we can work on the bars of each level of the shelf. The UICollectionView provides a header and a footer for each section. We can use the footer here to display a picture of the bar. Let's create a footer class called BookFooter, subclassing the UICollectionReusableView class. This part is like the way we creating our custom BookCell class, so we just skip the details here.

After getting our custom footer class, let's register it in viewDidLoad method before using it:

[self.collectionView registerClass:[BookHeaderView class] forSupplementaryViewOfKind:UICollectionElementKindSectionHeader withReuseIdentifier:@"Book Header"];

Overriding the viewForSupplementaryElementOfKind method:

- (UICollectionReusableView *)collectionView:(UICollectionView *)collectionView viewForSupplementaryElementOfKind:(NSString *)kind atIndexPath:(NSIndexPath *)indexPath{
    UICollectionReusableView *reusableview = nil;

    if (kind == UICollectionElementKindSectionHeader) {
        // codes configuring the header
    }

    if (kind == UICollectionElementKindSectionFooter) {
        BookFooterView *v = [collectionView dequeueReusableSupplementaryViewOfKind:UICollectionElementKindSectionFooter withReuseIdentifier:@"Book Footer" forIndexPath:indexPath];
        reusableview = v;
    }
    return reusableview;
}

If you run project at this point, you won't find any headers or footers in the view. Because we need to tell the application about the size of these elements. Overriding the below methond to set the suitable size for the footer:

- (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout *)collectionViewLayout referenceSizeForFooterInSection:(NSInteger)section{
    return CGSizeMake(self.collectionView.frame.size.width, 34);
}

Some tiny improvements

By now our bookshelf is substantially completed. However we can add some more features to make it looks more real. First of all, we can add a nice shadow of our book covers, in cellForItemAtIndexPath method:

cell.imageView.layer.shadowColor = [UIColor blackColor].CGColor;
cell.imageView.layer.shadowOffset = CGSizeMake(1, 1);
cell.imageView.layer.shadowOpacity = 0.5;
cell.imageView.layer.shadowRadius = 3.0;
cell.imageView.clipsToBounds = NO;

Also, still in the cellForItemAtIndexPath, we may make a little transform of the cover. Currently, covers is exactly parallel to the screen. We make want it flip for some angle horizontally, make the books look like lying on the back board of our bookshelf. Here, we need to use a homogenous transform of the image to make this effect. Using the below codes to implement the transformation:

CALayer *layer = cell.imageView.layer;
CATransform3D rotationAndPerspectiveTransform = CATransform3DIdentity;
rotationAndPerspectiveTransform.m34 = 1.0 / -500;
rotationAndPerspectiveTransform = CATransform3DRotate(rotationAndPerspectiveTransform, 10.0f * M_PI / 180.0f, 1.0f, 0.0f, 0.0f);
layer.transform = rotationAndPerspectiveTransform;

Cache

Now our view has a beautiful appearance, however since it request image from remote resource, we can cache the images for a better performace. Define a NSCache object named imageCache at the beginning of the view controller, Modify the relavent part in cellForItemAtIndexPath method:

UIImage *image = [imageCache objectForKey:imageURL];
if(image){
    cell.imageView.image = image;
}else{
    cell.imageView.image = nil;
    dispatch_queue_t downloadQueue = dispatch_queue_create("image downloader", NULL);
    dispatch_async(downloadQueue, ^{
        NSData *data = [NSData dataWithContentsOfURL:imageURL];
        UIImage *image = [UIImage imageWithData:data];
        dispatch_async(dispatch_get_main_queue(), ^{
            cell.imageView.image = image;
        });
        [imageCache setObject:image forKey:imageURL];
    });
}

You can download the whole project here.

Creative Commons BY-NC-ND 3.0