How to Make a Gesture-Driven To-Do List App Like Clear: Part 1/3
This is a post by Tutorial Team Member Colin Eberhardt, CTO of ShinobiControls, creators of playful and powerful iOS controls. Check out their app, ShinobiPlay. You can find Colin on Google+ and Twitter This three-part tutorial series will take you through the development of a simple to-do list application that is free from buttons, toggle […] By Colin Eberhardt.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
How to Make a Gesture-Driven To-Do List App Like Clear: Part 1/3
40 mins
Styling Your Cells
Before you start adding gestures, let’s make the list a little bit easier on the eyes. :]
The UITableView class has a separate protocol definition that is used for styling UITableViewDelegate. Switch to SHCViewController.h and add this protocol to the list:
@interface SHCViewController : UIViewController <UITableViewDataSource, UITableViewDelegate>
And of course, the table view delegate has to be set either via Interface Builder or code. Do it in code by adding the following to the end of viewDidLoad in SHCViewController.m:
self.tableView.delegate = self;
self.tableView.separatorStyle = UITableViewCellSeparatorStyleNone;
self.tableView.backgroundColor = [UIColor blackColor];
The code also removes the separators and changes the background color for the table view.
You can now add the code below to the end of the file to increase the height of each row and to set the background color per row:
-(UIColor*)colorForIndex:(NSInteger) index {
NSUInteger itemCount = _toDoItems.count - 1;
float val = ((float)index / (float)itemCount) * 0.6;
return [UIColor colorWithRed: 1.0 green:val blue: 0.0 alpha:1.0];
}
#pragma mark - UITableViewDataDelegate protocol methods
-(CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
return 50.0f;
}
-(void)tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath {
cell.backgroundColor = [self colorForIndex:indexPath.row];
}
The color returned by colorForIndex creates a gradient effect from red to yellow, just for aesthetic purposes. Build and run the app again to see this in action:
The current implementation sets a specific color for each row. While the overall effect is a gradient color change as the user scrolls down, notice that it’s hard to tell where one cell begins and another ends, especially towards the top, where most of the cells have a red background.
So the next step is to add a gradient effect to each cell (i.e., row) so that it’s easier to tell the cells apart. You could easily modify the cell’s appearance in the datasource or delegate methods that you have already implemented, but a much more elegant solution is to subclass UITableViewCell and customize the cell directly.
Add a new class to the project with the iOS\Cocoa Touch\Objective-C class template. Name the class SHCTableViewCell, and make it a subclass of UITableViewCell:
Replace the contents of SHCTableViewCell.m with the following:
#import <QuartzCore/QuartzCore.h>
#import "SHCTableViewCell.h"
@implementation SHCTableViewCell
{
CAGradientLayer* _gradientLayer;
}
-(id)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier {
self = [super initWithStyle:style reuseIdentifier:reuseIdentifier];
if (self) {
// add a layer that overlays the cell adding a subtle gradient effect
_gradientLayer = [CAGradientLayer layer];
_gradientLayer.frame = self.bounds;
_gradientLayer.colors = @[(id)[[UIColor colorWithWhite:1.0f alpha:0.2f] CGColor],
(id)[[UIColor colorWithWhite:1.0f alpha:0.1f] CGColor],
(id)[[UIColor clearColor] CGColor],
(id)[[UIColor colorWithWhite:0.0f alpha:0.1f] CGColor]];
_gradientLayer.locations = @[@0.00f, @0.01f, @0.95f, @1.00f];
[self.layer insertSublayer:_gradientLayer atIndex:0];
}
return self;
}
-(void) layoutSubviews {
[super layoutSubviews];
// ensure the gradient layers occupies the full bounds
_gradientLayer.frame = self.bounds;
}
@end
Here you add a CAGradientLayer instance variable and create a four-step gradient within the init method. Notice that the gradient is a transparent white at the very top, and a transparent black at the very bottom. This will be overlaid on top of the existing color background, to cause the effect of lightening the top and darkening the bottom, to create a neat bevel effect simulating a light source shining down from the top.
Note: Still trying to get your head wrapped around how to properly shade user interfaces and other graphics to simulate lighting? Check out this lighting tutorial by Vicki.
Note: Still trying to get your head wrapped around how to properly shade user interfaces and other graphics to simulate lighting? Check out this lighting tutorial by Vicki.
Also notice that layoutSubviews has been overridden. This is to ensure that the newly-added gradient layer always occupies the full bounds of the frame.
Try compiling this code, and you will find that the compiler reports a couple of linker errors. This is because the above code uses the QuartzCore framework.
To keep the compiler happy, click on the project root to bring up the project settings page, then expand the Link Binary With Libraries section of the Build Phases tab, and click the plus (+) button that allows you to add frameworks to your project. You should find QuartzCore on the list.
That’s the framework done, but you’re still not using your new custom UITableView cell in your code! You need to switch over to using the custom cell before you can see your new code in action.
Switch to SHCViewController.m and add the following import line at the top:
#import "SHCTableViewCell.h"
Then, replace the following line in viewDidLoad:
[self.tableView registerClass:[UITableViewCell class] forCellReuseIdentifier:@"cell"];
With this:
[self.tableView registerClass:[SHCTableViewCell class] forCellReuseIdentifier:@"cell"];
Finally, change the cell class in tableView:cellForRowAtIndexPath: to SHCTableCellClass, as follows (and make sure the label’s background is clear) as follows:
SHCTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:ident forIndexPath:indexPath];
cell.textLabel.backgroundColor = [UIColor clearColor];
That’s it! Since you register the class to be used to create a new table view cell in viewDidLoad, when tableView:cellForRowAtIndexPath: next needs a table cell, your new class will be used automatically. :]
Build and run your app, and your to-do items should now have a subtle gradient, making it much easier to differentiate between individual rows:
Swipe-to-Delete
Now that your list is presentable, it’s time to add your first gesture. This is an exciting moment!
Multi-touch devices provide app developers with complex and detailed information regarding user interactions. As each finger is placed on the screen, its position is tracked and reported to your app as a series of touch events. Mapping these low-level touch events to higher-level gestures, such as pan or a pinch, is quite challenging.
A finger is not exactly the most accurate pointing device! And as a result, gestures need to have a built-in tolerance. For example, a user’s finger has to move a certain distance before a gesture is considered a pan.
Fortunately, the iOS framework provides a set of gesture recognizers that has this all covered. These handy little classes manage the low-level touch events, saving you from the complex task of identifying the type of gesture, and allowing you to focus on the higher-level task of responding to each gesture.
This tutorial will skip over the details, but if you want to learn more check out our UIGestureRecognizer tutorial.
Add a pan gesture recognizer to your custom table view cell by adding the following code to the end of init (within the if condition) in SHCTableViewCell.m:
// add a pan recognizer
UIGestureRecognizer* recognizer = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(handlePan:)];
recognizer.delegate = self;
[self addGestureRecognizer:recognizer];
Any pan events will be sent to handlePan:, but before adding that method, you need a couple of instance variables. Add the following at the top of the file, right below the existing _gradientLayer instance variable:
CGPoint _originalCenter;
BOOL _deleteOnDragRelease;
Now add the method to the end of the file:
#pragma mark - horizontal pan gesture methods
-(BOOL)gestureRecognizerShouldBegin:(UIPanGestureRecognizer *)gestureRecognizer {
CGPoint translation = [gestureRecognizer translationInView:[self superview]];
// Check for horizontal gesture
if (fabsf(translation.x) > fabsf(translation.y)) {
return YES;
}
return NO;
}
-(void)handlePan:(UIPanGestureRecognizer *)recognizer {
// 1
if (recognizer.state == UIGestureRecognizerStateBegan) {
// if the gesture has just started, record the current centre location
_originalCenter = self.center;
}
// 2
if (recognizer.state == UIGestureRecognizerStateChanged) {
// translate the center
CGPoint translation = [recognizer translationInView:self];
self.center = CGPointMake(_originalCenter.x + translation.x, _originalCenter.y);
// determine whether the item has been dragged far enough to initiate a delete / complete
_deleteOnDragRelease = self.frame.origin.x < -self.frame.size.width / 2;
}
// 3
if (recognizer.state == UIGestureRecognizerStateEnded) {
// the frame this cell would have had before being dragged
CGRect originalFrame = CGRectMake(0, self.frame.origin.y,
self.bounds.size.width, self.bounds.size.height);
if (!_deleteOnDragRelease) {
// if the item is not being deleted, snap back to the original location
[UIView animateWithDuration:0.2
animations:^{
self.frame = originalFrame;
}
];
}
}
}
There’s a fair bit going on in this code. Let's start with handlePan:, section by section.
- Gesture handlers, such as this method, are invoked at various points within the gesture lifecycle: the start, change (i.e., when a gesture is in progress), and end. When the pan first starts, the center location of the cell is recorded in _originalCenter.
- As the pan gesture progresses (as the user moves their finger), the method determines the offset that should be applied to the cell (to show the cell being dragged) by getting the new location based on the gesture, and offsetting the center property accordingly. If the offset is greater than half the width of the cell, you consider this to be a delete operation. The _deleteOnDragRelease instance variable acts as a flag that indicates whether or not the operation is a delete.
- And of course, when the gesture ends, you check the flag to see if the action was a delete or not (the user might have dragged the cell more than halfway and then dragged it back, effectively nullifying the delete operation).
Then there's gestureRecognizerShouldBegin – what does that do? You might have noticed that as well as providing handlePan: as the action for the gesture, the code above also indicates that the cell class is being used as the delegate for the pan gesture.
This method allows you to cancel a gesture before it has begun. In this case, you determine whether the pan that is about to be initiated is horizontal or vertical. If it is vertical you cancel it, since you don't want to handle any vertical pans.
This is a very important step! Your cells are hosted within a vertically scrolling view. Failure to cancel a vertical pan renders the scroll view inoperable, and the to-do list will no longer scroll.
Build and run this code, and you should find that you can now drag the items left or right. When you release, the item snaps back to the center, unless you drag it more than half way across the screen to the left, indicating that the item should be deleted:
Of course, you'll also notice that the item doesn't actually get deleted. :] So how do you remove an item from your list?
The to-do items are stored in an NSMutableArray within your view controller. So you need to find some way to signal to the view controller that an item has been deleted and should be removed from this array.
UI controls use protocols to indicate state change and user interactions. You can adopt the same approach here.
Add a new protocol to the project with the iOS\Cocoa Touch\Objective-C protocol template. Name it SHCTableViewCellDelegate.
Now open SHCTableViewCellDelegate.h and replace its contents with:
#import "SHCToDoItem.h"
// A protocol that the SHCTableViewCell uses to inform of state change
@protocol SHCTableViewCellDelegate <NSObject>
// indicates that the given item has been deleted
-(void) toDoItemDeleted:(SHCToDoItem*)todoItem;
@end
The above code adds a single method that indicates an item has been deleted.
The custom cell class needs to expose this delegate, but it also needs to know which model item (i.e., SHCToDoItem) it is rendering. Replace the contents of SHCTableViewCell.h with the following to add this information:
#import "SHCToDoItem.h"
#import "SHCTableViewCellDelegate.h"
// A custom table cell that renders SHCToDoItem items.
@interface SHCTableViewCell : UITableViewCell
// The item that this cell renders.
@property (nonatomic) SHCToDoItem *todoItem;
// The object that acts as delegate for this cell.
@property (nonatomic, assign) id<SHCTableViewCellDelegate> delegate;
@end
In order to use this delegate, update the logic for handlePan: in SHCTableViewCell.h by adding the following code to the end of the last if block (the one checking whether the gesture state is ended):
if (_deleteOnDragRelease) {
// notify the delegate that this item should be deleted
[self.delegate toDoItemDeleted:self.todoItem];
}
The above code invokes the delegate method if the user has dragged the item far enough.
Now it's time to make use of the above changes. Switch to SHCViewController.h and declare the class as supporting the new protocol (and also add the necessary #import):
#import "SHCTableViewCellDelegate.h"
@interface SHCViewController : UIViewController <UITableViewDataSource, UITableViewDelegate, SHCTableViewCellDelegate>
Then, open SHCViewController.m and add the following line to the end of tableView:cellForRowAtIndex: (right before the return statement):
cell.delegate = self;
cell.todoItem = item;
Finally, add an implementation for the newly added delegate method to delete an item when necessary:
-(void)toDoItemDeleted:(id)todoItem {
// use the UITableView to animate the removal of this row
NSUInteger index = [_toDoItems indexOfObject:todoItem];
[self.tableView beginUpdates];
[_toDoItems removeObject:todoItem];
[self.tableView deleteRowsAtIndexPaths:@[[NSIndexPath indexPathForRow:index inSection:0]]
withRowAnimation:UITableViewRowAnimationFade];
[self.tableView endUpdates];
}
The above code removes the to-do item, and then uses the UITableView to animate the deletion, using one of its stock effects.