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 3 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 Pinch-To-Add Gesture

The final feature you'll add to the app will allow the user to insert a new to-do item in the middle of the list by pinching apart two neighboring rows of the table. Designing an interface to achieve this sort of functionality without the use of gestures would probably result in something quite cluttered and clunky. In fact, for this very reason, there are not many apps that support a mid-list insert.

The pinch is a natural gesture for adding a new to-do item between two existing ones. It allows the user to quite literally part the list exactly where they want the new item to appear. To implement this feature, you’ll add a UIPinchGestureRecognizer property to ViewController and you’ll use the same placeHolderCell that you created for the drag-to-add gesture.

Open ViewController.swift and set up your pinchRecognizer at the top of the class block, just below the toDoItems property:

let pinchRecognizer = UIPinchGestureRecognizer()

Then, in viewDidLoad, just below the call to super.viewDidLoad set its handler and add it to tableView:

pinchRecognizer.addTarget(self, action: "handlePinch:")
tableView.addGestureRecognizer(pinchRecognizer)

You're going to add quite a lot of code to ViewController.swift, to handle the pinch-to-add gesture, so set up a "skeleton" pinch-to-add methods group just before the // MARK: - UIScrollViewDelegate methods group:

// MARK: - pinch-to-add methods

// indicates that the pinch is in progress
var pinchInProgress = false

func handlePinch(recognizer: UIPinchGestureRecognizer) {
  if recognizer.state == .Began {
    pinchStarted(recognizer)
  }
  if recognizer.state == .Changed && pinchInProgress && recognizer.numberOfTouches() == 2 {
    pinchChanged(recognizer)
  }
  if recognizer.state == .Ended {
    pinchEnded(recognizer)
  }
}

func pinchStarted(recognizer: UIPinchGestureRecognizer) {
    
}

func pinchChanged(recognizer: UIPinchGestureRecognizer) {
    
}

func pinchEnded(recognizer: UIPinchGestureRecognizer) {
    
}

The handlePinch method is called when a pinch gesture starts, changes (i.e., the user moves their finger), and ends. This method just hands the recognizer on to helper methods, which you'll write soon. Notice that it's not enough for the pinch gesture to just change - you only want to handle this if there's a pinch in progress. Only the pinchStarted method can set pinchInProgress to true, and this method won't be called unless the user is touching in exactly two places.

In order to allow the user to pinch apart two rows of the table, you need to detect whether their fingers are touching two neighboring to-do items, keep track of how far apart they're moving their fingers, and move the other visible cells, to provide a visual representation of the rows moving apart to make room for a new item. If the user ends the pinch gesture after parting two neighboring rows by at least the height of a table cell, then you need to figure out the index values of the two neighboring items, insert a new array item at the correct index, and handover control to your existing cell-editing code.

To do all of this, you'll need a few more properties and helper methods:

  • a TouchPoints structure to hold the upper and lower CGPoints where the user is touching the screen
  • initialTouchPoints - a TouchPoints instance to hold the points where the user first touches the screen
  • upperCellIndex and lowerCellIndex - properties to store the index values (in the toDoItems array) of the items that the user first touches; the new item will be added at lowerCellIndex
  • pinchExceededRequiredDistance - a Bool that flags whether the user parted the rows far enough to add a new item
  • getNormalizedTouchPoints - a helper method to ensure that the upper point is really above the lower point, by swapping them if necessary
  • viewContainsPoint - a helper method that checks whether a CGPoint is in a view

And so, to work! Add the following to ViewController.swift in the // MARK: - pinch-to-add methods group, just before the pinchInProgress property:

struct TouchPoints {
  var upper: CGPoint
  var lower: CGPoint
}
// the indices of the upper and lower cells that are being pinched
var upperCellIndex = -100
var lowerCellIndex = -100
// the location of the touch points when the pinch began
var initialTouchPoints: TouchPoints!
// indicates that the pinch was big enough to cause a new item to be added
var pinchExceededRequiredDistance = false

Now add the helper methods, below the empty pinchEnded method:

// returns the two touch points, ordering them to ensure that
// upper and lower are correctly identified.
func getNormalizedTouchPoints(recognizer: UIGestureRecognizer) -> TouchPoints {
  var pointOne = recognizer.locationOfTouch(0, inView: tableView)
  var pointTwo = recognizer.locationOfTouch(1, inView: tableView)
  // ensure pointOne is the top-most
  if pointOne.y > pointTwo.y {
    let temp = pointOne
    pointOne = pointTwo
    pointTwo = temp
  }
  return TouchPoints(upper: pointOne, lower: pointTwo)
}

func viewContainsPoint(view: UIView, point: CGPoint) -> Bool {
    let frame = view.frame
    return (frame.origin.y < point.y) && (frame.origin.y + (frame.size.height) > point.y)
}

getNormalizedTouchPoints gets the two points from the recognizer and swaps them if pointOne is actually below pointTwo (larger y-coordinate means farther down in the tableView).

viewContainsPoint hit-tests a view to see whether it contains a point. This is as simple as checking whether the point "lands" within the frame. The cells are full-width, so this method only needs to check the y-coordinate.

Note: getNormalizedTouchPoints is another case where Swift's y-coordinate is different from the Objective-C version. In Objective-C, the recognizer.locationOfTouch y-coordinate must be incremented (offset) by scrollView.contentOffset.y, but Swift's y-coordinate is already offset. For example, if you have scrolled down so that items 10 to 20 are visible (scrollView.contentOffset.y is 500), the Objective-C y-coordinate of item 12 is 100 (its position in the visible part of the table) but the Swift y-coordinate of item 12 is 600 (its position in the whole table).

Note: getNormalizedTouchPoints is another case where Swift's y-coordinate is different from the Objective-C version. In Objective-C, the recognizer.locationOfTouch y-coordinate must be incremented (offset) by scrollView.contentOffset.y, but Swift's y-coordinate is already offset. For example, if you have scrolled down so that items 10 to 20 are visible (scrollView.contentOffset.y is 500), the Objective-C y-coordinate of item 12 is 100 (its position in the visible part of the table) but the Swift y-coordinate of item 12 is 600 (its position in the whole table).

Your first task is to detect the start of the pinch. The two helper methods enable you to locate the cells that are touched by the user and determine whether they are neighbors. You can now fill in the details for pinchStarted:

func pinchStarted(recognizer: UIPinchGestureRecognizer) {
  // find the touch-points
  initialTouchPoints = getNormalizedTouchPoints(recognizer)
  
  // locate the cells that these points touch
  upperCellIndex = -100
  lowerCellIndex = -100
  let visibleCells = tableView.visibleCells()  as! [TableViewCell]
  for i in 0..<visibleCells.count {
    let cell = visibleCells[i]
    if viewContainsPoint(cell, point: initialTouchPoints.upper) {
      upperCellIndex = i
      // highlight the cell – just for debugging!
      cell.backgroundColor = UIColor.purpleColor()
    }
    if viewContainsPoint(cell, point: initialTouchPoints.lower) {
      lowerCellIndex = i
      // highlight the cell – just for debugging!
      cell.backgroundColor = UIColor.purpleColor()
    }
  }
  // check whether they are neighbors
  if abs(upperCellIndex - lowerCellIndex) == 1 {
    // initiate the pinch
    pinchInProgress = true
    // show placeholder cell
    let precedingCell = visibleCells[upperCellIndex]
    placeHolderCell.frame = CGRectOffset(precedingCell.frame, 0.0, tableView.rowHeight / 2.0)
    placeHolderCell.backgroundColor = UIColor.redColor()
    tableView.insertSubview(placeHolderCell, atIndex: 0)
  }
}

As the inline comments indicate, the above code finds the initial touch points, locates the cells that were touched, and then checks if they are neighbors. This is simply a matter of comparing their indices. If they are neighbors, pinchInProgress is set to true, and then the app displays the cell placeholder that shows it will insert the new cell - although, at this point, you won't see the placeholder cell, because you haven't yet written the code that moves the rows apart.

Now build and run.

When developing multi-touch interactions, it really helps to add visual feedback for debugging purposes. In this case, it helps to ensure that the scroll offset is being correctly applied! If you place two fingers on the list, you will see the to-do items are highlighted purple:

PurpleItems

Note: While it is possible to test the app on the Simulator, you might find it easier to test this part on a device. If you do decide to use the Simulator, you can hold down the Option key on your keyboard to see where the two touch points would lie, and carefully reposition them so that things work correctly. :]

In fact, even on a device you might find this a difficult feat if you have fairly large fingers. I found that the best way to get two cells selected was to try pinching not with thumb and forefinger, but with fingers from two different hands.

These are just teething issues that you can feel free to fix by increasing the height of the cells, for instance. And increasing the height of the cells is as simple as changing the value of tableView.rowHeight.

Note: While it is possible to test the app on the Simulator, you might find it easier to test this part on a device. If you do decide to use the Simulator, you can hold down the Option key on your keyboard to see where the two touch points would lie, and carefully reposition them so that things work correctly. :]

In fact, even on a device you might find this a difficult feat if you have fairly large fingers. I found that the best way to get two cells selected was to try pinching not with thumb and forefinger, but with fingers from two different hands.

These are just teething issues that you can feel free to fix by increasing the height of the cells, for instance. And increasing the height of the cells is as simple as changing the value of tableView.rowHeight.

The next step is to handle the pinch and part the list. Remember that handlePinch requires three conditions before it calls pinchChanged:

if recognizer.state == .Changed 
  && pinchInProgress 
  && recognizer.numberOfTouches() == 2 {
    pinchChanged(recognizer)
}

And pinchInProgress was set to true in pinchStarted: only if the touch points are on two neighboring items. So pinchChanged only handles the right kind of pinch:

func pinchChanged(recognizer: UIPinchGestureRecognizer) {
  // find the touch points
  let currentTouchPoints = getNormalizedTouchPoints(recognizer)
  
  // determine by how much each touch point has changed, and take the minimum delta
  let upperDelta = currentTouchPoints.upper.y - initialTouchPoints.upper.y
  let lowerDelta = initialTouchPoints.lower.y - currentTouchPoints.lower.y
  let delta = -min(0, min(upperDelta, lowerDelta))
  
  // offset the cells, negative for the cells above, positive for those below
  let visibleCells = tableView.visibleCells() as! [TableViewCell]
  for i in 0..<visibleCells.count {
    let cell = visibleCells[i]
    if i <= upperCellIndex {
      cell.transform = CGAffineTransformMakeTranslation(0, -delta)
    }
    if i >= lowerCellIndex {
      cell.transform = CGAffineTransformMakeTranslation(0, delta)
    }
  }
}

The implementation for pinchChanged: determines the delta, i.e., by how much the user has moved their finger, then applies a transform to each cell in the list: positive for items below the parting, and negative for those above.

Build, run, and have fun parting the list!

PartingTheList

As the list parts, you want to scale the placeholder cell so that it appears to “spring out” from between the two items that are being parted. Add the following to the end of pinchChanged:

// scale the placeholder cell
let gapSize = delta * 2
let cappedGapSize = min(gapSize, tableView.rowHeight)
placeHolderCell.transform = CGAffineTransformMakeScale(1.0, cappedGapSize / tableView.rowHeight)
placeHolderCell.label.text = gapSize > tableView.rowHeight ? "Release to add item" : "Pull apart to add item"
placeHolderCell.alpha = min(1.0, gapSize / tableView.rowHeight)

// has the user pinched far enough?
pinchExceededRequiredDistance = gapSize > tableView.rowHeight

The scale transform, combined with a change in alpha, creates quite a pleasing effect:

PartingPartTwo

You can probably turn off that purple highlight now :] and, near the end of pinchStarted, set the placeHolderCell.backgroundColor to match the cell above it (instead of just redColor):

placeHolderCell.backgroundColor = precedingCell.backgroundColor

You might have noticed the property pinchExceededRequiredDistance, which is set at the end of pinchChanged. This records whether the user has “parted” the list by more than the height of one row. In this case, when the user finishes the pinch gesture (pinchEnded), you need to add a new item to the list.

But before finishing the gesture code, you need to modify the toDoItemAdded method to allow insertion of an item at any index. Look at this method in ViewController.swift and you'll see that index 0 is hard-coded into it:

toDoItems.insert(toDoItem, atIndex: 0)

So toDoItemAddedAtIndex is easy - add an index argument and use that instead of 0 when calling the Array insert method. Replace the toDoItemAdded method with these lines:

func toDoItemAdded() {
  toDoItemAddedAtIndex(0)
}

func toDoItemAddedAtIndex(index: Int) {
  let toDoItem = ToDoItem(text: "")
  toDoItems.insert(toDoItem, atIndex: index)
  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
    }
  }
}

As before, as soon as an item is inserted into the list, it is immediately editable.

Now to implement pinchEnded!

func pinchEnded(recognizer: UIPinchGestureRecognizer) {
  pinchInProgress = false
  
  // remove the placeholder cell
  placeHolderCell.transform = CGAffineTransformIdentity
  placeHolderCell.removeFromSuperview()
  
  if pinchExceededRequiredDistance {
    pinchExceededRequiredDistance = false

    // Set all the cells back to the transform identity
    let visibleCells = self.tableView.visibleCells() as! [TableViewCell]
    for cell in visibleCells {
      cell.transform = CGAffineTransformIdentity
    }
        
    // add a new item
    let indexOffset = Int(floor(tableView.contentOffset.y / tableView.rowHeight))
    toDoItemAddedAtIndex(lowerCellIndex + indexOffset)
  } else {
    // otherwise, animate back to position
    UIView.animateWithDuration(0.2, delay: 0.0, options: .CurveEaseInOut, animations: {() in
      let visibleCells = self.tableView.visibleCells() as! [TableViewCell]
      for cell in visibleCells {
        cell.transform = CGAffineTransformIdentity
      }
    }, completion: nil)
  }
}

This method performs two different functions. First, if the user has pinched further than the height of a to-do item, toDoItemAddedAtIndex is invoked.

Otherwise, the list closes the gap between the two items. This is achieved using a simple animation. Earlier, when you coded the item-deleted animation, you used the completion block to re-render the entire table. With this gesture, the animation returns all of the cells back to their original positions, so it's not necessary to redraw the entire table.

In either scenario, it is important to reset the transform of each cell back to the identity transform with CGAffineTransformIdentity. This ensures the space created by your pinch gesture is removed when adding the new item. You'll rely on the first responder animation when an item is added, but you add your own basic animation if the cells are simply closed.

Notice that the flags pinchInProgress and pinchExceededRequiredDistance are set to false as soon as their true value is no longer needed - this prevents "fall-through" insertions, for example, when the initial touch points are on non-neighboring items but one or both flags were still true from the previous insertion.

And with that, your app is finally done. Build, run, and enjoy your completed to-do list with gesture-support!

Final

Contributors

Over 300 content creators. Join our team.