How To Make a Gesture-Driven To-Do List App Like Clear in Swift: Part 2/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.

Contents

Hide contents

How To Make a Gesture-Driven To-Do List App Like Clear in Swift: Part 2/2

45 mins

The Pull-to-Add Gesture

The gestures that feel the most natural tend to play on the illusion that the phone UI is a physical object that obeys the same laws of physics as the natural world. Deleting an item from the to-do list by “pulling” it off the side of the screen feels quite natural, in the same way that you might swiftly pull a straw out in a game of KerPlunk.

The pull-down gesture has become ubiquitous in mobile apps as a means to refresh a list. The pull-down gesture feels very much like you are pulling against the natural resistance of the list, as if it were a hanging rope, in order to physically pull more items in from the top. Again, it is a natural gesture that in some way reflects how things work in the “real” world.

There has been some concern about the legality of using the pull-to-refresh gesture, due to Twitter's user interface patent. However, the recent introduction of this feature in the iOS email application (with a gorgeous tear-drop effect), the iOS 6 SDK itself, and its popularity in the App Store means that developers are less concerned about this patent. And anyway, "Twitter agreed [with the inventor, Loren Brichter] to only use his patent defensively — the company wouldn't sue other companies that were using pull-to-refresh in apps unless those companies sued first" (quotation from the linked theverge.com article).

Note: To learn more about iOS 6's built-in pull-to-refresh control, check out Chapter 20 in iOS 6 by Tutorials, "What's New with Cocoa Touch."

Note: To learn more about iOS 6's built-in pull-to-refresh control, check out Chapter 20 in iOS 6 by Tutorials, "What's New with Cocoa Touch."

Pulling down on the list to add a new item at the top is a great gesture to add to your to-do list application, so in this part of the tutorial, you’ll start with that!

You'll add the new logic to ViewController - when you add more functionality, that class starts to get crowded, and it's important to organize the properties and methods into logical groupings. Add a group for UIScrollViewDelegate methods, between the TableViewCellDelegate methods and the TableViewDelegate methods:

// MARK: - Table view data source
// contains numberOfSectionsInTableView, numberOfRowsInSection, cellForRowAtIndexPath

// MARK: - TableViewCellDelegate methods
// contains toDoItemDeleted, cellDidBeginEditing, cellDidEndEditing

// MARK: - UIScrollViewDelegate methods
// contains scrollViewDidScroll, and other methods, to keep track of dragging the scrollView

// MARK: - TableViewDelegate methods
// contains willDisplayCell and your helper method colorForIndex 

In order to implement a pull-to-add gesture, you first have to detect when the user has started to scroll while at the top of the list. Then, as the user pulls further down, position a placeholder element that indicates where the new item will be added.

The placeholder can be an instance of TableViewCell, which renders each item in the list. So open ViewController.swift and add this line in the // MARK: - UIScrollViewDelegate methods group:

// a cell that is rendered as a placeholder to indicate where a new item is added
let placeHolderCell = TableViewCell(style: .Default, reuseIdentifier: "cell")

The above code simply sets up the property for the placeholder and initializes it.

Adding the placeholder when the pull gesture starts and maintaining its position is really quite straightforward. When dragging starts, check whether the user is currently at the start of the list, and if so, use a pullDownInProgress property to record this state.

Of course, you first have to add this new property to ViewController.swift (it goes right below the placeholderCell that you just declared):

// a cell that is rendered as a placeholder to indicate where a new item is added
let placeHolderCell = TableViewCell(style: .Default, reuseIdentifier: "cell")
// indicates the state of this behavior
var pullDownInProgress = false

Just below these two properties, add the UIScrollViewDelegate method necessary to detect the beginning of a pull:

func scrollViewWillBeginDragging(scrollView: UIScrollView) {
  // this behavior starts when a user pulls down while at the top of the table
  pullDownInProgress = scrollView.contentOffset.y <= 0.0
  placeHolderCell.backgroundColor = UIColor.redColor()
  if pullDownInProgress {
    // add the placeholder
    tableView.insertSubview(placeHolderCell, atIndex: 0)
  }
}

If the user starts pulling down from the top of the table, the y-coordinate of the scrollView content's origin goes from 0 to negative - this sets the pullDownInProgress flag to true. This code also sets the placeHolderCell's background color to red, then adds it to the tableView.

While a scroll is in progress, you need to reposition the placeholder by setting its frame in scrollViewDidScroll method. The values you need to set its frame are: x-, y-coordinates of its origin, width and height - x is 0, width is the same as the tableView.frame, but y and height depend on the cell height. In Part 1 of this tutorial, you used a constant row height by setting tableView.rowHeight, and you can use it in the method below.

Create a scrollViewDidScroll method as follows:

func scrollViewDidScroll(scrollView: UIScrollView) {
  var scrollViewContentOffsetY = scrollView.contentOffset.y
      
  if pullDownInProgress && scrollView.contentOffset.y <= 0.0 {
    // maintain the location of the placeholder
    placeHolderCell.frame = CGRect(x: 0, y: -tableView.rowHeight,
        width: tableView.frame.size.width, height: tableView.rowHeight)
    placeHolderCell.label.text = -scrollViewContentOffsetY > tableView.rowHeight ?
        "Release to add item" : "Pull to add item"
    placeHolderCell.alpha = min(1.0, -scrollViewContentOffsetY / tableView.rowHeight)
  } else {
    pullDownInProgress = false
  }
}

Note: Swift requires a placeHolderCell.frame y-coordinate that is different from the Objective-C version of this app. In Objective-C, the placeHolderCell.frame y-coordinate is -scrollView.contentOffset.y - tableView.rowHeight, to keep it at the top of the existing table, but Swift's y-coordinate is simply -tableView.rowHeight, i.e., its position relative to the top of the scrollView.

Note: Swift requires a placeHolderCell.frame y-coordinate that is different from the Objective-C version of this app. In Objective-C, the placeHolderCell.frame y-coordinate is -scrollView.contentOffset.y - tableView.rowHeight, to keep it at the top of the existing table, but Swift's y-coordinate is simply -tableView.rowHeight, i.e., its position relative to the top of the scrollView.

The code above simply maintains the placeholder as the user scrolls, adjusting its label text and alpha, depending on how far the user has dragged.

When the user stops dragging, you need to check whether they pulled down far enough (i.e., by at least the height of a cell), and remove the placeholder. You do this by adding the implementation of the UIScrollViewDelegate method scrollViewDidEndDragging::

func scrollViewDidEndDragging(scrollView: UIScrollView, willDecelerate decelerate: Bool) {
  // check whether the user pulled down far enough
  if pullDownInProgress && -scrollView.contentOffset.y > tableView.rowHeight {
    // TODO – add a new item
  }
  pullDownInProgress = false
  placeHolderCell.removeFromSuperview()
}

As you'll notice, the code doesn't actually insert a new item yet. Later on, you’ll take a look at the logic required to update your array of model objects.

As you've seen, implementing a pull-down gesture is really quite easy! Did you notice the way that the above code adjusts the placeholder alpha and flips its text from “Pull to Add Item” to “Release to Add Item”? These are contextual cues, as mentioned in Part 1 of this series (you do remember, don’t you?).

Now build and run to see your new gesture in action:

PullDownAddNew

When the drag gesture is completed, you need to add a new ToDoItem to the toDoItems array. You'll write a new method to do this, but where to put it? It isn't a TableViewCellDelegate method, but its purpose is closely related to those methods, which delete and edit to-do items, so put it in that group and change the group's title:

// MARK: - add, delete, edit methods

func toDoItemAdded() {
    let toDoItem = ToDoItem(text: "")
    toDoItems.insert(toDoItem, atIndex: 0)
    tableView.reloadData()
    // enter edit mode
    var editCell: TableViewCell
    let visibleCells = tableView.visibleCells() as! [TableViewCell]
    for cell in visibleCells {
        if (cell.toDoItem === toDoItem) {
            editCell = cell
            editCell.label.becomeFirstResponder()
            break
        }
    }
}

This code is pretty simple – it adds a new to-do item to the start of the array, and then forces an update of the table. Then it locates the cell that renders this newly added to-do item and sends a becomeFirstResponder: message to its text label in order to go straight into edit mode.

Next, remember to replace the // TODO – add a new item in scrollViewDidEndDragging with the call to toDoItemAdded, so the if block looks like this:

if pullDownInProgress && -scrollView.contentOffset.y > tableView.rowHeight {
    toDoItemAdded()
}

The end result is that as soon as a new item is added, the user can start entering the description for their to-do item:

EditNewItem

That’s pretty slick! And it works even if you start with an empty table, or delete all the items - you can still pull down to add a new item :]

But there's one more thing to consider: what if the user changes their mind, doesn't type anything, and just taps Enter to get rid of the keyboard? Your table will have a cell with nothing in it! You should check for non-empty text and, if a cell's text is empty, delete it by calling toDoItemDeleted - the deletion animation signals to the user that the app responded to their action, and didn't just ignore it, or crash.

If you trace through the code, you'll see that there are two places where you could check whether the user entered text - either in TableViewCell.swift's UITextFieldDelegate method textFieldDidEndEditing, or in ViewController.swift's TableViewCellDelegate method cellDidEndEditing.

This is how you'd do it in TableViewCell.swift's textFieldDidEndEditing:

func textFieldDidEndEditing(textField: UITextField!) {
  if delegate != nil {
    delegate!.cellDidEndEditing(self)
  }
  if toDoItem != nil {
    if textField.text == "" {
      delegate!.toDoItemDeleted(toDoItem!)
    } else {
      toDoItem!.text = textField.text
    }
  }
}

Notice that this code calls cellDidEndEditing before checking whether the user entered text - it's just that it seems tidier to get the table cells back to "normal" before deleting the new item. In practice, both things happen so quickly that it looks the same, either way.

You might choose to check for non-empty input in textFieldDidEndEditing, because it's closer to the (non-)event but, on the other hand, it seems presumptuous for a textField to make the decision to delete an item from the app's data model - a case of the tail wagging the dog. It seems more proper to let the TableViewCell's delegate make this decision...

So this is how you do it in ViewController.swift's cellDidEndEditing - you just add the if block - again, I've placed it after restoring the cells to normal but again, it doesn't matter in practice:

func cellDidEndEditing(editingCell: TableViewCell) {
  let visibleCells = tableView.visibleCells() as! [TableViewCell]
  for cell: TableViewCell in visibleCells {
    UIView.animateWithDuration(0.3, animations: {() in
      cell.transform = CGAffineTransformIdentity
      if cell !== editingCell {
        cell.alpha = 1.0
      }
    })
  }
  if editingCell.toDoItem!.text == "" {
    toDoItemDeleted(editingCell.toDoItem!)
  }
}

If you delete the empty cell in cellDidEndEditing, then textFieldDidEndEditing must set the to-do item's text property before it calls cellDidEndEditing, as you originally wrote it:

func textFieldDidEndEditing(textField: UITextField!) {
  if toDoItem != nil {
    toDoItem!.text = textField.text
  }
  if delegate != nil {
    delegate!.cellDidEndEditing(self)
  }
}

Build and run. Notice that if you edit an existing item to "", this code will delete it, which I think is what the user would expect.

EmptyEdit

Contributors

Over 300 content creators. Join our team.