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