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.

Leave a rating/review
Save for later
Share
You are currently viewing page 2 of 4 of this article. Click here to view the first page.

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:

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:

ToDoListColored

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.

AddCustomCell

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.

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:

ToDoListGradient

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.

  1. 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.
  2. 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.
  3. 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:

PseudoDelete

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.

StoryboardResetConstraints

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:

RealDelete

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...

Contributors

Over 300 content creators. Join our team.