How To Make a Gesture-Driven To-Do List App Like Clear in Swift: Part 1/2
Learn how to make a gesture-driven to-do list app like Clear, complete with table view tricks, swipes, and pinches. By Audrey Tam.
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 in Swift: Part 1/2
45 mins
Styling Your Cells
Before you start adding gestures, the next two steps make the list a little bit easier on the eyes. :]
You’ll use color to separate the table rows so, in ViewController‘s viewDidLoad, set the tableView‘s separator style to None. While you’re there, make the rows a little bigger to increase readability:
tableView.separatorStyle = .None
tableView.rowHeight = 50.0
Note: If you are planning to support versions of iOS prior to iOS 8, you may also need to implement heightForRowAtIndexPath
. Simply returning rowHeight
will be sufficient, as in the code below:
Note: If you are planning to support versions of iOS prior to iOS 8, you may also need to implement heightForRowAtIndexPath
. Simply returning rowHeight
will be sufficient, as in the code below:
func tableView(tableView: UITableView, heightForRowAtIndexPath
indexPath: NSIndexPath) -> CGFloat {
return tableView.rowHeight;
}
func tableView(tableView: UITableView, heightForRowAtIndexPath
indexPath: NSIndexPath) -> CGFloat {
return tableView.rowHeight;
}
The UIViewController
class also conforms to UITableViewDelegate
. Add the code below to the end of ViewController.swift to set the background color of each row, adding slightly more green as you go:
// MARK: - Table view delegate
func colorForIndex(index: Int) -> UIColor {
let itemCount = toDoItems.count - 1
let val = (CGFloat(index) / CGFloat(itemCount)) * 0.6
return UIColor(red: 1.0, green: val, blue: 0.0, alpha: 1.0)
}
func tableView(tableView: UITableView, willDisplayCell cell: UITableViewCell,
forRowAtIndexPath indexPath: NSIndexPath) {
cell.backgroundColor = colorForIndex(indexPath.row)
}
The color returned by colorForIndex(index:)
creates a gradient effect from red to yellow, just for aesthetic purposes. Build and run the app again to see that you’ve completed Checkpoint 2:
The current implementation sets a specific color for each row. While the overall effect is a gradient color change as the user scrolls down, it’s hard to tell where one cell begins and another ends, especially towards the top.
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\Source\Cocoa Touch Class template. Name the class TableViewCell and make it a subclass of UITableViewCell. Make sure you uncheck the option to create a XIB file and set the Language to Swift.
Replace the contents of TableViewCell.swift with the following:
import UIKit
import QuartzCore
class TableViewCell: UITableViewCell {
let gradientLayer = CAGradientLayer()
required init(coder aDecoder: NSCoder) {
fatalError("NSCoding not supported")
}
override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
// gradient layer for cell
gradientLayer.frame = bounds
let color1 = UIColor(white: 1.0, alpha: 0.2).CGColor as CGColorRef
let color2 = UIColor(white: 1.0, alpha: 0.1).CGColor as CGColorRef
let color3 = UIColor.clearColor().CGColor as CGColorRef
let color4 = UIColor(white: 0.0, alpha: 0.1).CGColor as CGColorRef
gradientLayer.colors = [color1, color2, color3, color4]
gradientLayer.locations = [0.0, 0.01, 0.95, 1.0]
layer.insertSublayer(gradientLayer, atIndex: 0)
}
override func layoutSubviews() {
super.layoutSubviews()
gradientLayer.frame = bounds
}
}
Here you add a CAGradientLayer
property 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, 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.
Now you need to switch over to using your new custom UITableView
cell in your code! Only two steps are required:
Step 1: In ViewController.swift‘s viewDidLoad, change the cell class from UITableViewCell
to TableViewCell:
tableView.registerClass(TableViewCell.self, forCellReuseIdentifier: "cell")
This tells the tableView
to use the TableViewCell class whenever it needs a cell with reuse identifier “cell”.
Step 2: Change the cell class cast in cellForRowAtIndexPath to TableViewCell (and make sure the label’s background color is clear, so the gradient shines through), as follows:
let cell = tableView.dequeueReusableCellWithIdentifier("cell", forIndexPath: indexPath)
as TableViewCell
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 check off Checkpoint 3: 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.
Two small tweaks in ViewController.swift before you really get stuck into this: add this line to viewDidLoad, just after you set tableView.separatorStyle
:
tableView.backgroundColor = UIColor.blackColor()
This makes the tableView black, under the cell you’re dragging.
And add this line in cellForRowAtIndexPath
, after the line that creates the cell:
cell.selectionStyle = .None
This gets rid of the highlighting that happens when you select a table cell.
Open TableViewCell.swift and add the following code at the end of the overridden init method:
// add a pan recognizer
var recognizer = UIPanGestureRecognizer(target: self, action: "handlePan:")
recognizer.delegate = self
addGestureRecognizer(recognizer)
This code adds a pan gesture recognizer to your custom table view cell, and sets the cell itself as the recognizer’s delegate. Any pan events will be sent to handlePan
but, before adding that method, you need to set up two properties that it will use.
Add these two properties at the top of TableViewCell.swift, right below the existing gradientLayer
property:
var originalCenter = CGPoint()
var deleteOnDragRelease = false
Now add the implementation for handlePan
, at the end of TableViewCell.swift:
//MARK: - horizontal pan gesture methods
func handlePan(recognizer: UIPanGestureRecognizer) {
// 1
if recognizer.state == .Began {
// when the gesture begins, record the current center location
originalCenter = center
}
// 2
if recognizer.state == .Changed {
let translation = recognizer.translationInView(self)
center = CGPointMake(originalCenter.x + translation.x, originalCenter.y)
// has the user dragged the item far enough to initiate a delete/complete?
deleteOnDragRelease = frame.origin.x < -frame.size.width / 2.0
}
// 3
if recognizer.state == .Ended {
// the frame this cell had before user dragged it
let originalFrame = CGRect(x: 0, y: frame.origin.y,
width: bounds.size.width, height: 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 go through 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
property 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).
Next, give the recognizer's delegate (the table view cell) something to do by adding this UIGestureRecognizerDelegate method, below handlePan:
override func gestureRecognizerShouldBegin(gestureRecognizer: UIGestureRecognizer) -> Bool {
if let panGestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer {
let translation = panGestureRecognizer.translationInView(superview!)
if fabs(translation.x) > fabs(translation.y) {
return true
}
return false
}
return false
}
This delegate method allows you to cancel the recognition of 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 the gesture recognizer, 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 halfway across the screen to the left, indicating that the item should be deleted:
Note: If you find you have to drag an item much more than halfway across to the left, to get it to stick, you need to set constraints on the Table View, to stop it from overflowing the device's window - in Main.storyboard, select the View Controller then click the Resolve Auto Layout Issues button and select All Views in View Controller\Reset to Suggested Constraints. This will make the Table View fit into the device's window.
Note: If you find you have to drag an item much more than halfway across to the left, to get it to stick, you need to set constraints on the Table View, to stop it from overflowing the device's window - in Main.storyboard, select the View Controller then click the Resolve Auto Layout Issues button and select All Views in View Controller\Reset to Suggested Constraints. This will make the Table View fit into the device's window.
And that's Checkpoint 4 done! Of course, you'll notice that the cell just gets stuck and doesn't actually disappear, and if you scroll it off the screen then back again, the item is still there - to complete this swipe-to-delete gesture, you need to remove the item from your list and reload the table.
The to-do items are stored in an Array
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.
To add a new protocol to the project, add this code to TableViewCell.swift, below the import statements but above the class TableViewCell block:
// A protocol that the TableViewCell uses to inform its delegate of state change
protocol TableViewCellDelegate {
// indicates that the given item has been deleted
func toDoItemDeleted(todoItem: ToDoItem)
}
This code defines a protocol with a required method that indicates an item has been deleted.
The TableViewCell
class needs to expose this delegate, and it also needs to know which model item (i.e., ToDoItem
) it is rendering, so that it can pass this item to its delegate.
Add these two properties near the top of TableViewCell.swift, just below the definitions of originalCenter and deleteOnDragRelease:
// The object that acts as delegate for this cell.
var delegate: TableViewCellDelegate?
// The item that this cell renders.
var toDoItem: ToDoItem?
You declare these properties as optionals, because you'll set their values in ViewController.swift
, not in TableViewCell
's init
method.
In order to use this delegate, update the logic for handlePan in TableViewCell.swift by adding the following code to the end of the if recognizer.state == .Ended block:
if deleteOnDragRelease {
if delegate != nil && toDoItem != nil {
// notify the delegate that this item should be deleted
delegate!.toDoItemDeleted(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 ViewController.swift and, just above the // MARK: - Table view delegate
line, add an implementation for the TableViewCellDelegate
method toDoItemDeleted, to delete an item when notified:
func toDoItemDeleted(toDoItem: ToDoItem) {
let index = (toDoItems as NSArray).indexOfObject(toDoItem)
if index == NSNotFound { return }
// could removeAtIndex in the loop but keep it here for when indexOfObject works
toDoItems.removeAtIndex(index)
// use the UITableView to animate the removal of this row
tableView.beginUpdates()
let indexPathForRow = NSIndexPath(forRow: index, inSection: 0)
tableView.deleteRowsAtIndexPaths([indexPathForRow], withRowAnimation: .Fade)
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.
Next, scroll up to the top of ViewController.swift and declare that this class conforms to your new protocol:
class ViewController: UIViewController, UITableViewDataSource, UITableViewDelegate, TableViewCellDelegate {
Finally, add the following lines to the end of cellForRowAtIndexPath (right before the return statement) to set the TableViewCell
's delegate
and toDoItem
properties:
cell.delegate = self
cell.toDoItem = item
Build and run your project, and delete some items, to check off Checkpoint 5:
Only two more Checkpoints to go! This next one is pretty long and a bit intense, with a small DIY exercise, so feel free to take a short coffee/tea break before we continue...