This tutorial will teach you how to use the iOS 6 UICollectionView
class to build a "Springboard" layout that looks and acts like the iOS Home Screen! Along the way, you'll gain a solid grasp of collection view fundamentals in order to create your own grid based layouts.
Project Preview
Collection View Overview
Collection Views are an exciting new addition to the UIKit API in iOS 6. Some platform pundits are of the opinion that collection views are poised to become as fundamental and pervasive a component in iOS app design as table views have been (and your author is humbly inclined to agree!). Collection views and table views share a lot of common design philosophy (not to mention a similar API) because they serve a common purpose - to present a collection of related data. It is best not to think of collection views as a replacement to table views, but rather as a new offering of a more generic and flexible architecture for interacting with non-tabular data sets.
Because collection views are intended to be flexible, extensible and configurable, the architecture and hence API may seem to be a bit daunting at first. There are a number of interacting classes that one needs to know about. Depending on how simple or complex your requirements are, you might need to subclass some of these classes to achieve the results you want. Fortunately, the API is designed from the ground up to help with that. We’ll see several concrete examples of this later in the tutorial.
Let’s start by taking a brief overview of the main classes in the collection view framework. In the collection view world, a concrete unit of data is visually encapsulated by a UICollectionViewCell
instance. The UICollectionView
class provides a scrollable container that holds a collection of cells. While the collection view is aware of the position of cells, their visibility (or lack thereof) is not actually the responsibility of the class. The responsibility of handling the layout falls on the shoulders of the UICollectionViewLayout
class. By switching the layout class for the same collection view, we could cause the same data to be displayed in a dramatically different way.
Speaking of visual elements, besides cells there are two others: supplementary views and decoration views. Data being displayed might be logically organized into sections, and supplementary views might be used to supply section-wise "metadata" (think headers and footers in a table view). On the other hand, decoration views are "ornamental" rather than data-driven: think of the bookshelf backdrop in the iBooks app. Of course, I’ve just mentioned these for the sake of completeness. The UI for this project's tutorial will be comprised entirely of cell elements and there won’t be any sections.
The UICollectionViewLayout
is not restricted to a particular type of layout, but it ships with a subclass, UICollectionViewFlowLayout
, that has been designed for constructing both line and grid-based layouts.
Much like table views, a collection view interacts with the underlying data through a data source and responds to UI events with the help of a delegate.
The layout class can manage the layout in a couple of ways - by setting global properties on cells or by means of delegation. The latter endows the layout class with finer grained control of the appearance of the UI.
There are of course some more classes to be mentioned, and I’ll come to them later.
Tutorial Approach
Let’s talk about appearances first: in this tutorial, we’re going to create a UI that looks like the iPhone/iPad home screen (also called the SpringBoard). It will be comprised of a scrollable grid of icons. Swiping left or right will present a new page of icons. Just like the SpringBoard, the interface will have a static background (a "wallpaper") behind it. Not only that, we’ll also implement icon deletion: long-pressing on any of the icons will trigger deletion mode. The icons will then start quaking (from fear of being deleted!), and a close button will present itself on each of them. Clicking on which will cause the icon to disappear from the grid. Clicking on an icon itself will modally present a new view that can be dismissed with a button to bring back our spring board view.
Behind the scenes, the UI is supported by a very simple data model (which we shall introduce shortly). This is so that we can concentrate on the interface implementation, with minimum added complexity thrown in by the "model" end of the MVC architecture. The icons will present part of the data (like the cells do in table views) and the view presented modally upon clicking on any icon will present the rest of our data.
A Barebones Implementation
Since this is (presumably) the first time we’re designing a layout with collection views, it’s easy to get swamped with lots of interacting objects and lose the bigger picture. To avoid this, I want to kick things off with a minimalistic implementation that still fulfils our essential requirement of a grid-based scrolling layout, and then explore how we can harness the power of the collection view framework to polish our UI and introduce new features.
Fire up Xcode, create a new project with an "Empty Template" (we’ll do everything in code for this tutorial) and call it "SpringBoardLayoutTut". Make sure it’s an iPad project, and that ARC is enabled.
Deselect all modes except Portrait from "Supported Interface Orientations".
Replace all the code in AppDelegate.m with the following:
#import "AppDelegate.h" @interface ViewController : UICollectionViewController @end @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; [self.collectionView registerClass:[UICollectionViewCell class] forCellWithReuseIdentifier:@"ID"]; } // collection view data source methods //////////////////////////////////// - (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section { return 100; } - (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath { UICollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"ID" forIndexPath:indexPath]; UILabel *label = [[UILabel alloc] initWithFrame:cell.bounds]; label.textAlignment = NSTextAlignmentCenter; label.text = [NSString stringWithFormat:@"%d", indexPath.row]; [cell.contentView addSubview:label]; return cell; } ///////////////////////////////////////////////////////////////////////////////// // collection view delegate methods //////////////////////////////////////// - (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath { NSLog(@"cell #%d was selected", indexPath.row); } ///////////////////////////////////////////////////////////////////////////////// @end @implementation AppDelegate { ViewController *vc; } - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { self.window = [[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]]; UICollectionViewFlowLayout *layout = [[UICollectionViewFlowLayout alloc] init]; vc = [[ViewController alloc] initWithCollectionViewLayout:layout]; // setting cell attributes globally via layout properties /////////////// layout.itemSize = CGSizeMake(128, 128); layout.minimumInteritemSpacing = 64; layout.minimumLineSpacing = 64; layout.scrollDirection = UICollectionViewScrollDirectionHorizontal; layout.sectionInset = UIEdgeInsetsMake(32, 32, 32, 32); ///////////////////////////////////////////////////////////////////////////// self.window.rootViewController = vc; self.window.backgroundColor = [UIColor whiteColor]; [self.window makeKeyAndVisible]; return YES; } @end
Build the app. You should get a (very) basic grid interface.
The above code is only meant to illustrate that a basic grid layout is available pretty much out-of-the-box with the UICollectionViewFlowLayout
class. It is not meant to illustrate how you would actually structure your code; that’s what the rest of the tutorial is for!
If you know your table views, a lot of the above code should look familiar.
Our root view controller is an instance of UICollectionViewController
, which comes with a UICollectionView
instance as its view, and is, by default, set to be the collection view’s data source and delegate. We register the UICollectionViewCell
class with the collection view controller and give it an identifier. The UICollectionViewCell
is meant to be subclassed, as we shall soon, which is why the registration/identification step is needed. We also create an instance of the UICollectionViewFlowLayout
, set it as our collection view’s layout, and set some properties on it defining how our cells will be sized and positioned on the grid. Our "data" just consists of a hundred integers. Note the familiar cell dequeuing step from table views, which recycles cells that have been moved off-screen to save on memory and cell creation. Unlike in iOS 5, the -dequeueReusableCellWithReuseIdentifier: method automatically returns a new cell if none exist in the reuse queue. When a cell is due to appear on the screen, a label is attached to it that displays one of the numbers from our integer list. Clicking on a cell just gives an acknowledgement in the form of a log message.
To reiterate, the point of the above code was just to show you that the basic grid layout is very easy to achieve with collection views and the flow layout class.
The rest of the tutorial is focused on making the UI look pretty, as well as more interactive and functional.
Creating a Simple Data Model
Let's get down to business. We'll start by creating a very simple data model, which consists of an array containing the names of all the font families installed on iOS, and a dictionary containing the fonts themselves, keyed by their respective font families.
Create an NSObject
subclass called SimpleModel
and replace the contents of SimpleModel.h and SimpleModel.m with the following:
// SimpleModel.h #import <Foundation/Foundation.h> @interface SimpleModel : NSObject @property (nonatomic, strong) NSMutableArray *fontFamilies; @property (nonatomic, strong) NSMutableDictionary *fontFaces; @end // SimpleModel.m #import "SimpleModel.h" @implementation SimpleModel - (id)init { if (self = [super init]) { self.fontFamilies = [NSMutableArray arrayWithArray:[UIFont familyNames]]; self.fontFaces = [NSMutableDictionary dictionaryWithCapacity:self.fontFamilies.count]; for ( NSString *familyName in self.fontFamilies) { NSArray *fontsList = [UIFont fontNamesForFamilyName:familyName]; [self.fontFaces setObject:fontsList forKey:familyName]; } } return self; } @end
Customizing the Icon
Our second order of business is to subclass UICollectionViewCell
in order to make our icons look more like, well, icons!
Add the QuartzCore framework to your project. We’ll need this because we’ll be twiddling with CALayer
properties and animating them.
Create a new Objective-C file, make it a UICollectionViewCell
subclass and call it Icon
. Replace the code in the Icon.h and Icon.m files with the following:
// Icon.h #import <UIKit/UIKit.h> #import <QuartzCore/QuartzCore.h> @interface Icon : UICollectionViewCell @property (nonatomic, strong) UILabel *label; //@property (nonatomic, strong) UIButton *deleteButton; // TO UNCOMMENT LATER @end // Icon.m #define MARGIN 2 #import "Icon.h" #import <QuartzCore/QuartzCore.h> //#import "SpringboardLayoutAttributes.h" // TO UNCOMMENT LATER //static UIImage *deleteButtonImg; // TO UNCOMMENT LATER @implementation Icon - (id)initWithFrame:(CGRect)frame { self = [super initWithFrame:frame]; if (self) { UIView *insetView = [[UIView alloc] initWithFrame:CGRectInset(self.bounds, self.bounds.size.width/8, self.bounds.size.height/8)]; [self.contentView addSubview:insetView]; self.layer.shouldRasterize = YES; self.label = [[UILabel alloc] initWithFrame:CGRectMake(0, 0, insetView.frame.size.width, insetView.frame.size.height)]; self.label.autoresizingMask = UIViewAutoresizingFlexibleHeight | UIViewAutoresizingFlexibleHeight; self.label.textAlignment = NSTextAlignmentCenter; self.label.numberOfLines = 3; self.label.lineBreakMode = NSLineBreakByWordWrapping; float dim = MIN(self.label.bounds.size.width, self.label.bounds.size.height); self.label.clipsToBounds = YES; self.label.layer.cornerRadius = dim/8; self.label.layer.opacity = 0.7; self.label.layer.borderColor = [UIColor darkGrayColor].CGColor; self.label.layer.borderWidth = 1.0; self.label.font = [UIFont systemFontOfSize:dim/6]; self.label.backgroundColor = [UIColor lightGrayColor]; [insetView addSubview:self.label]; // INSERT ICON DELETE BUTTON SNIPPET HERE } return self; } // INSERT LAYOUT ATTRIBUTE APPLICATION SNIPPET HERE // INSERT CELL ANIMATION SNIPPET HERE @end
Our icon cell's content view is inset with a slightly smaller view (added as a subview) which serves to hold our UILabel
instance which is the main visible feature of our icon. The reason for using the inset subview is so we can later add a delete button that visually extends beyond the bounds of the label (so that it looks cool) yet lies within the bounds of the invisible content view (because making a control register touches when its bounds rectangle lies outside its parent view is a bit tricky and we would like to avoid that).
I’ve commented out some things that aren’t part of our app as it stands currently. I’ll ask you to uncomment them later. Alternatively, we’ll be filling in code wherever it says // INSERT ... SNIPPET HERE.
I’ve done things this way so we can work on our app progressively instead of getting bogged down with all the details at once.
Customizing the Layout
Create a new Objective-C class file, make it a UICollectionViewFlowLayout
subclass and name it SpringboardLayout
. Replace the code in SpringboardLayout.h and SpringboardLayout.m with the following:
// SpringboardLayout.h #import <UIKit/UIKit.h> // INSERT DELEGATE PROTOCOL SNIPPET HERE @interface SpringboardLayout : UICollectionViewFlowLayout @end // SpringboardLayout.m #import "SpringboardLayout.h" // #import "SpringboardLayoutAttributes.h" // UNCOMMENT LATER @implementation SpringboardLayout - (id)init { if (self = [super init]) { self.itemSize = CGSizeMake(144, 144); self.minimumInteritemSpacing = 48; self.minimumLineSpacing = 48; self.scrollDirection = UICollectionViewScrollDirectionHorizontal; self.sectionInset = UIEdgeInsetsMake(32, 32, 32, 32); } return self; } // INSERT DELETION MODE SNIPPET HERE // INSERT ATTRIBUTES SNIPPET HERE @end
Creating a View Controller
Create a new Objective-C class file. Call the class ViewController
and make it a subclass of UICollectionViewController
. Replace the code in ViewController.m with the following:
// ViewController.m #import "ViewController.h" #import "SimpleModel.h" #import "Icon.h" #import "SpringboardLayout.h" @implementation ViewController { SimpleModel *model; // BOOL isDeletionModeActive; // TO UNCOMMENT LATER } - (void)viewDidLoad { [super viewDidLoad]; model = [[SimpleModel alloc] init]; [self.collectionView registerClass:[Icon class] forCellWithReuseIdentifier:@"ICON"]; //INSERT GESTURE RECOGNIZER CREATION SNIPPET HERE: } #pragma mark - data source methods - (NSInteger)numberOfSectionsInCollectionView:(UICollectionView *)collectionView { return 1; } - (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section { return model.fontFamilies.count; } - (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath { Icon *icon = [collectionView dequeueReusableCellWithReuseIdentifier:@"ICON" forIndexPath:indexPath]; icon.label.text = [model.fontFamilies objectAtIndex:indexPath.row]; // INSERT DELETE BUTTON CONFIG SNIPPET HERE // INSERT DELETE BUTTON ACTION SNIPPET HERE } #pragma mark - delegate methods - (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath { UIViewController *vc = [[UIViewController alloc] init]; vc.view.frame = [UIScreen mainScreen].bounds; NSMutableAttributedString *attribString = [[NSMutableAttributedString alloc] init]; NSArray *faces = [model.fontFaces objectForKey:[model.fontFamilies objectAtIndex:indexPath.row]]; for ( NSString *face in faces) { [attribString appendAttributedString:[[NSAttributedString alloc] initWithString: [NSString stringWithFormat:@"%@\n", face] attributes:[NSDictionary dictionaryWithObject:[UIFont fontWithName:face size:30.0] forKey:NSFontAttributeName]]]; } UITextView *content = [[UITextView alloc] initWithFrame:vc.view.bounds]; content.attributedText = attribString; content.textAlignment = NSTextAlignmentCenter; content.editable = NO; [vc.view addSubview:content]; UIButton *b = [UIButton buttonWithType:UIButtonTypeRoundedRect]; b.frame = CGRectMake(vc.view.bounds.size.width/2 - 40, vc.view.bounds.size.height - 100, 80, 60); [b setTitle:@"Close" forState:UIControlStateNormal]; [b addTarget:self action:@selector(dismiss) forControlEvents:UIControlEventTouchUpInside]; [vc.view addSubview:b]; vc.modalTransitionStyle = UIModalTransitionStyleCrossDissolve; [self presentViewController:vc animated:YES completion:nil]; } // INSERT SHOULD SELECT SNIPPET HERE #pragma mark - dismiss modally presented view controller - (void)dismiss { [self dismissViewControllerAnimated:YES completion:nil]; } // INSERT GESTURE RECOGNIZER ACTIONS SNIPPET HERE // INSERT LAYOUT DELEGATE SNIPPET HERE @end
Configuring the App Delegate
We’re almost ready to build the new and improved version of our interface.
Add the following two images to your project: Image One, Image Two. They’re going to serve as the backdrop to our springboard-like interface (thanks to Fabio of www.999wallpapers.com for generously letting me use them!).
Replace the contents of AppDelegate.m
with the following code that creates and configures the view controller and assigns it to a layout:
// AppDelegate.m #import "AppDelegate.h" #import "ViewController.h" #import "SpringboardLayout.h" @implementation AppDelegate { SpringboardLayout *springboardLayout; ViewController *viewController; } - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { self.window = [[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]]; springboardLayout = [[SpringboardLayout alloc] init]; viewController = [[ViewController alloc] initWithCollectionViewLayout:springboardLayout]; self.window.rootViewController = viewController; viewController.collectionView.pagingEnabled = YES; viewController.collectionView.backgroundView = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"coolrobot.png"]]; self.window.backgroundColor = [UIColor whiteColor]; [self.window makeKeyAndVisible]; return YES; } @end
Build the app. The UI should have a more sophisticated look and behavior.
A Brief Recap - and Looking Ahead
Let’s take a step back and review what we’ve done so far, and how the code we’ve written translates our requirements in terms of the framework.
We created an Icon
class that knows how to draw itself, and has a label that we can populate with our data. The details of our layout are managed by the SpringboardLayout
class, which we subclassed from UICollectionViewLayout
, which is intended for building line or grid based layouts. At this moment, if you were to make the observation that we didn’t really need to subclass the UICollectionViewFlowLayout
class as we haven’t really extended it’s behaviour in any way, only set a few of its properties, well, you’d be smack on! Since all our cells are visually identical, we were able to get away by just setting some global properties on the layout class to get the grid-like arrangement we wanted, and - if that’s all we wanted - we could have done that on a UICollectionViewFlowLayout
instance itself (as we did the first time 'round). We’ve subclassed UICollectionViewFlowLayout
in anticipation of the functionality that we’ll be adding to our UI shortly.
At this point, I’ll mention that the other way of passing information to the layout is through delegation. This would be useful if we wanted our cells to have different sizes, for instance, or if we had multiple sections in our UI and we wanted a slightly different look for each section.
The actual data presentation uses the familiar data source and delegation mechanisms that we know and love from table views. The UICollectionViewController
instance acts as both by default. The collection view determines the number of cells it contains and the contents of each cell by querying the data source, and it informs the delegate when the user taps on a cell so that it can respond appropriately. In our example, we respond by instantiating and modally presenting a view controller that displays a list of the font faces corresponding to the font family in the clicked cell.
Now, let’s consider our next objective, and continue to organize our code so that each class in the framework carries out the responsibilities it’s been designed for, no more and no less.
Implementing Deletion Mode
In Icon.h, uncomment the line:
@property (nonatomic, strong) UIButton *deleteButton;
In Icon.m, uncomment:
static UIImage *deleteButtonImg;
Then insert the following block of code at the appropriate place:
// INSERT ICON DELETE BUTTON SNIPPET HERE self.deleteButton = [[UIButton alloc] initWithFrame:CGRectMake(frame.size.width/16, frame.size.width/16, frame.size.width/4, frame.size.width/4)]; if (!deleteButtonImg) { CGRect buttonFrame = self.deleteButton.frame; UIGraphicsBeginImageContext(buttonFrame.size); CGFloat sz = MIN(buttonFrame.size.width, buttonFrame.size.height); UIBezierPath *path = [UIBezierPath bezierPathWithArcCenter:CGPointMake(buttonFrame.size.width/2, buttonFrame.size.height/2) radius:sz/2-MARGIN startAngle:0 endAngle:M_PI * 2 clockwise:YES]; [path moveToPoint:CGPointMake(MARGIN, MARGIN)]; [path addLineToPoint:CGPointMake(sz-MARGIN, sz-MARGIN)]; [path moveToPoint:CGPointMake(MARGIN, sz-MARGIN)]; [path addLineToPoint:CGPointMake(sz-MARGIN, MARGIN)]; [[UIColor redColor] setFill]; [[UIColor whiteColor] setStroke]; [path setLineWidth:3.0]; [path fill]; [path stroke]; deleteButtonImg = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); } [self.deleteButton setImage:deleteButtonImg forState:UIControlStateNormal]; [self.contentView addSubview:self.deleteButton];
We could have included our close button as a bitmap image with our app but since it's fairly simple to create as a vector graphic with UIBezierPath
(which we then convert to a bitmap), we’ve done that instead. One of the advantages of this approach (i.e. creating a vector path and then rasterizing) is that we don’t need to worry about providing multiple images for devices at different resolutions.
Let’s create the target-action method for our close button.
In ViewController.m
, insert the following code snippets:
// INSERT DELETE BUTTON CONFIG SNIPPET HERE [icon.deleteButton addTarget:self action:@selector(delete:) forControlEvents:UIControlEventTouchUpInside];
and
// INSERT DELETE BUTTON ACTION SNIPPET HERE #pragma mark - delete for button - (void)delete:(UIButton *)sender { NSIndexPath *indexPath = [self.collectionView indexPathForCell:(Icon *)sender.superview.superview]; [model.fontFaces removeObjectForKey:[model.fontFamilies objectAtIndex:indexPath.row]]; [model.fontFamilies removeObjectAtIndex:indexPath.row]; [self.collectionView deleteItemsAtIndexPaths:[NSArray arrayWithObject:indexPath]]; } [/obj] If you were to build and run the app at this point, you’d see a close button with a slightly oversized cross on the upper-left corner of each icon. Clicking on it will cause the icon to be removed from the view, in sync with the deletion of the appropriate data items from our model, of course. While we’re at it, let’s also write the wriggly animation code for the icons. Insert the following block of code at the appropriate location, in <strong>Icon.m</strong>: [objc] // INSERT CELL ANIMATION SNIPPET HERE - (void)startQuivering { CABasicAnimation *quiverAnim = [CABasicAnimation animationWithKeyPath:@"transform.rotation"]; float startAngle = (-2) * M_PI/180.0; float stopAngle = -startAngle; quiverAnim.fromValue = [NSNumber numberWithFloat:startAngle]; quiverAnim.toValue = [NSNumber numberWithFloat:3 * stopAngle]; quiverAnim.autoreverses = YES; quiverAnim.duration = 0.2; quiverAnim.repeatCount = HUGE_VALF; float timeOffset = (float)(arc4random() % 100)/100 - 0.50; quiverAnim.timeOffset = timeOffset; CALayer *layer = self.layer; [layer addAnimation:quiverAnim forKey:@"quivering"]; } - (void)stopQuivering { CALayer *layer = self.layer; [layer removeAnimationForKey:@"quivering"]; }
Basically, our animation causes our icon to rock (uneasily) from side-to-side. By adding a smaller random time offset to each icon, we prevent the icons from animating in sync, which would have weakened the illusion we’re going for.
If you want to take a peek at what the animation looks like, stick in the statement [self startQuivering];
at the end of the if(self) {…} block in the Icon
init method. Just be sure to remove it afterward, though! The screenshot below shows the delete button and the icons in different phases of the rotation animation.
We now want to introduce a deletion mode into our app that is activated and deactivated by the appropriate gestures, instead of having the delete button permanently active.
It seems reasonable to make the collection view controller responsible for activating and deactivating the deletion mode. Let’s introduce a boolean variable to keep track of the mode, and override a delegate method that indicates whether it is permitted for a cell the user taps on to be selected or not. We’ll make this method return YES or NO depending on the value of the boolean variable.
In ViewController.m, uncomment the declaration:
BOOL isDeletionModeActive;
Next add the following method at the indicated place in the file:
// INSERT SHOULD SELECT SNIPPET HERE - (BOOL)collectionView:(UICollectionView *)collectionView shouldSelectItemAtIndexPath:(NSIndexPath *)indexPath { if (isDeletionModeActive) return NO; else return YES; }
A bit further down, we’ll implement a gesture recognizer that can change the value of this variable.
Now then, how do we pass this information to the SpringboardLayout
class? We might consider defining a corresponding boolean property on the layout class that the view controller can change. But this creates an unnecessary coupling between the view controller and layout class. Plus, we’ll need to ensure that we synchronise changes in both the property values (i.e. in the view controller and layout). Design-wise, a much better solution is to use delegation: the method required by the protocol lets the layout query its delegate about the state of the app. In fact, by defining a protocol here, we’re mimicking or, rather, extending Apple’s own approach wherein they’ve defined UIViewControllerDelegateFlowLayout
that can supply information to the UIViewControllerFlowLayout
class. All of the methods in this protocol are optional. If not implemented, the layout falls back on the global properties (that we in fact have set), as I mentioned earlier. A technical point here is that this protocol already extends the UICollectionViewDelegate
protocol, and we’re going to be extending it further.
Our protocol will contain a method that the layout can query to determine whether the deletion mode is active or not.
In SpringboardLayout.h add the following protocol declaration:
// INSERT DELEGATE PROTOCOL SNIPPET HERE @protocol SpringboardLayoutDelegate @required - (BOOL)isDeletionModeActiveForCollectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout *)collectionViewLayout; @end
I’ve added parameters for the collection view and the layout to be consistent with how Apple defined theirs. The idea is that the same object might act as a delegate for multiple collection views or layouts, and we might need to resolve between them.
In ViewController.h, add #import "SpringboardLayout.h"
and declare that the controller adopts our protocol, by appending <SpringboardLayoutDelegate>
at the end of the @interface statement, so that it reads:
@interface ViewController : UICollectionViewController<code><SpringboardLayoutDelegate></code>
In ViewController.m, implement the required method:
// INSERT LAYOUT DELEGATE SNIPPET HERE: #pragma mark - spring board layout delegate - (BOOL) isDeletionModeActiveForCollectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout { return isDeletionModeActive; }
Admittedly, all we’re doing is passing the value of the boolean mode variable to the layout, but in a larger project, it could make a lot of difference in terms of code reuse and maintainability.
In SpringboardLayout, insert this convenience method:
// INSERT DELETION MODE SNIPPET HERE - (BOOL)isDeletionModeOn { if ([[self.collectionView.delegate class] conformsToProtocol:@protocol(SpringboardLayoutDelegate)]) { return [(id)self.collectionView.delegate isDeletionModeActiveForCollectionView:self.collectionView layout:self]; } return NO; }
Note that we want to reuse the same delegate object (which is a property on the collection view class) rather than have to create and set a new one. So we first check whether the delegate conforms to our protocol and if so, we send it the appropriate message. By default, the UICollectionViewController
class (and hence our subclass ViewController
) is its collection view’s delegate and data source. And since we announced that ViewController
conforms to the SpringboardLayoutDelegate
, that’s why the if condition in the above method will be true.
Now how do we percolate this information down to the cells, to get them to reveal or hide the close button, and start/stop the animation?
Since our SpringboardLayout
instance is responsible for configuring cell properties that have to do with visual presentation and layout of the cells, it ought to be responsible for calling the appropriate changes on the cells when our deletion mode is activated or deactivated. How exactly? This is part of the collection view architecture that we deferred discussion of, earlier.
Apple’s collection view architecture introduces yet another class, called UICollectionViewLayoutAttributes
, that vectors information from the layout to the cell. Attribute instances are created for each cell, and these can very well be configured to depend on some property of the state of our app. In our particular case, think normal state vs. deletion state. The cells receive these attributes and configure themselves accordingly.
While at first glance it might seem that this architecture inserts an unnecessary layer of indirection, it’s actually quite smart. In letting the attributes class mediate the communication between the layout and the cells, we could, say, create different subclasses of cells for the same interface, that interpret the attributes differently, leading to different visual behaviours.
The UICollectionViewLayoutAttribute
class contains properties that correspond directly with many of the cell’s visual properties (like opacity, transform, etc.), but we’ll need to extend it so that we can define an attribute that conveys whether the app is in deletion mode or not. Let’s do that now.
Create a new subclass of UICollectionViewLayoutAttributes
called SpringboardLayoutAttributes
. Replace the contents of the *.h and *.m files with the following:
// SpringboardLayoutAttributes.h #import <UIKit/UIKit.h> @interface SpringboardLayoutAttributes : UICollectionViewLayoutAttributes @property (nonatomic, getter = isDeleteButtonHidden) BOOL deleteButtonHidden; @end // SpringboardLayoutAttributes.m #import "SpringboardLayoutAttributes.h" @implementation SpringboardLayoutAttributes - (id)copyWithZone:(NSZone *)zone { SpringboardLayoutAttributes *attributes = [super copyWithZone:zone]; attributes.deleteButtonHidden = _deleteButtonHidden; return attributes; } @end
We’ve introduced a boolean property that defines whether we want the delete button to be hidden or not. The method -copyWithZone:
(a method of NSObject
) needs to be overridden so that the newly added property in the subclass is taken into account when an attributes object is copied.
Uncomment the import statement #import "SpringboardLayoutAttributes.h"
and insert the following methods that inform the layout of the exact class of the attributes (our custom SpringboardLayoutAttributes
class) and the methods that helps it create cell attributes keeping in consideration whether deletion mode is active or not:
+ (Class)layoutAttributesClass { return [SpringboardLayoutAttributes class]; } - (SpringboardLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath { SpringboardLayoutAttributes *attributes = (SpringboardLayoutAttributes *)[super layoutAttributesForItemAtIndexPath:indexPath]; if ([self isDeletionModeOn]) attributes.deleteButtonHidden = NO; else attributes.deleteButtonHidden = YES; return attributes; } - (NSArray *)layoutAttributesForElementsInRect:(CGRect)rect { NSArray *attributesArrayInRect = [super layoutAttributesForElementsInRect:rect]; for (SpringboardLayoutAttributes *attribs in attributesArrayInRect) { if ([self isDeletionModeOn]) attribs.deleteButtonHidden = NO; else attribs.deleteButtonHidden = YES; } return attributesArrayInRect; }
The final step is to override a method inherited by our Icon
class that lets it configure its instance in accordance with the attributes relayed to it.
In Cell.m insert the following method at the indicated spot:
// INSERT LAYOUT ATTRIBUTE APPLICATION SNIPPET HERE - (void)applyLayoutAttributes:(SpringboardLayoutAttributes *)layoutAttributes { if (layoutAttributes.isDeleteButtonHidden) { self.deleteButton.layer.opacity = 0.0; [self stopQuivering]; } else { self.deleteButton.layer.opacity = 1.0; [self startQuivering]; } }
Now all that’s left to do is to set up our gesture recognizers to activate and deactivate the deletion mode.
In ViewController.h, modify the @interface statement to declare that the view controller class conforms to the UIGestureRecognizerDelegate class:
@interface ViewController : UICollectionViewController<UIGestureRecognizerDelegate>
In the ViewController.m class, insert the following snippet in the -viewDidLoad: method at the indicated place:
//INSERT GESTURE RECOGNIZER SNIPPET CREATION HERE: UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(activateDeletionMode:)]; longPress.delegate = self; [self.collectionView addGestureRecognizer:longPress]; UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(endDeletionMode:)]; tap.delegate = self; [self.collectionView addGestureRecognizer:tap];
Insert the action methods for the gesture recognizers:
// INSERT GESTURE RECOGNIZER ACTIONS HERE #pragma mark - gesture-recognition action methods - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch { CGPoint touchPoint = [touch locationInView:self.collectionView]; NSIndexPath *indexPath = [self.collectionView indexPathForItemAtPoint:touchPoint]; if (indexPath && [gestureRecognizer isKindOfClass:[UITapGestureRecognizer class]]) { return NO; } return YES; } - (void)activateDeletionMode:(UILongPressGestureRecognizer *)gr { if (gr.state == UIGestureRecognizerStateBegan) { NSIndexPath *indexPath = [self.collectionView indexPathForItemAtPoint:[gr locationInView:self.collectionView]]; if (indexPath) { isDeletionModeActive = YES; SpringboardLayout *layout = (SpringboardLayout *)self.collectionView.collectionViewLayout; [layout invalidateLayout]; } } } - (void)endDeletionMode:(UITapGestureRecognizer *)gr { if (isDeletionModeActive) { NSIndexPath *indexPath = [self.collectionView indexPathForItemAtPoint:[gr locationInView:self.collectionView]]; if (!indexPath) { isDeletionModeActive = NO; SpringboardLayout *layout = (SpringboardLayout *)self.collectionView.collectionViewLayout; [layout invalidateLayout]; } } }
Observe our use of the very convenient -indexPathForItemAtPoint: method in UICollectionView
that lets us check whether our long press or tap falls within the bounds of a cell item or not. Also, observe the very key message * [layout invalidateLayout]*. This signals the occurrence of some event that requires the interface be rebuilt. In this event, it is the activation or deactivation of the deletion mode. New attributes are generated by the SpringboardLayout
class for all the visible cells (with the deletion mode property having been toggled), and cells apply these attributes to themselves.
Build the project, and check out the cool, animated deletion mode you just built! Long-pressing on any icon activates it, and tapping anywhere in the background disables it, just like in the iOS home screen.
Where To Go From Here?
I would recommend that you read The Collection View Programming Guide for iOS and watch sessions 205 and 219 (introductory and advanced collection views, respectively) from WWDC 2012. The code for some of the examples presented in these talks are available in the session code too, so you can play around with them.
You might like to extend the example we’ve built so far to endow it with more Springboard-like behaviour, such as making the icons fly into position when the app loads for the first time, or enable dragging of icons and the ability to swap their positions. You might like to peruse the API for the different classes in the collection view framework, as there are a lot of methods that we haven’t touched upon in this tutorial, that you might require or be able to make good use of.
I hope this tutorial has given you a solid basis to get started with your own collection view based app. Thanks for reading!
Comments