Quantcast
Channel: Kodeco | High quality programming tutorials: iOS, Android, Swift, Kotlin, Unity, and more
Viewing all articles
Browse latest Browse all 4370

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

$
0
0
Learn how to make a stylish gesture driven to-do app like Clear!

Learn how to make a stylish gesture driven to-do app like Clear!

Update note: This tutorial was fully updated for iOS 8 and Swift by Audrey Tam. Originally posted as Part 2/3 and Part 3/3 by Colin Eberhardt.

This is the second in a two-part tutorial series that takes you through developing a to-do list app that is completely free of buttons, toggle switches and other common, increasingly outdated user interface (UI) controls.

It’s nothing but swipes, pulls and pinches for this app! As I’m sure you’ve realized if you’ve been following along, that leaves a lot more room for content.

If you followed the first part of the tutorial, you should now have a stylish and minimalistic to-do list interface. Your users can mark items as complete by swiping them to the right, or delete them by swiping to the left.

Before moving on to adding more gestures to the app, this part of the tutorial will show you how to make a few improvements to the existing interactions.

Right now, the animation that accompanies a delete operation is a “stock” feature of UITableView – when an item is deleted, it fades away, while the items below move up to fill the space. This effect is a little jarring, and the animation a bit dull.

How about if instead, the deleted item continued its motion to the right, while the remaining items shuffled up to fill the space?

Ready to see how easy it can be to do one better than Apple’s stock table view animations? Let’s get started!

A Funky Delete Animation

This part of the tutorial continues on from the previous one. If you did not follow Part 1, or just want to jump in at this stage, make sure you download the code from the first part, since you’ll be building on it in this tutorial.

Open ViewController.swift and find toDoItemDeleted. Presently, the code for animating the deletion of a to-do item is as follows:

tableView.beginUpdates()
let indexPathForRow = NSIndexPath(forRow: index, inSection: 0)
tableView.deleteRowsAtIndexPaths([indexPathForRow], withRowAnimation: .Fade)
tableView.endUpdates()

This uses the “stock” UITableViewRowAnimation.Fade effect, which is a bit boring! I’d much prefer the application to use a more eye-catching animation, where the items shuffle upwards to fill the space that was occupied by the deleted item.

The UITableView manages the lifecycle of your cells, so how do you manually animate their location? It’s surprisingly easy! UITableView includes the visibleCells method, which returns an array of all the cells that are currently visible. You can iterate over these items and do what you like with them!

So, let’s replace the stock animation with something a bit more exciting.

You’re going to use block-based animations, as described in detail in our How to Use UIView Animation tutorial. In ViewController.swift), replace the current todoItemDeleted implementation with the following:

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)
 
  // loop over the visible cells to animate delete
  let visibleCells = tableView.visibleCells() as [TableViewCell]
  let lastView = visibleCells[visibleCells.count - 1] as TableViewCell
  var delay = 0.0
  var startAnimating = false
  for i in 0..<visibleCells.count {
    let cell = visibleCells[i]
    if startAnimating {
      UIView.animateWithDuration(0.3, delay: delay, options: .CurveEaseInOut,
        animations: {() in
          cell.frame = CGRectOffset(cell.frame, 0.0, 
                       -cell.frame.size.height)},
        completion: {(finished: Bool) in
          if (cell == lastView) {
            self.tableView.reloadData()
          }
        }
      )
      delay += 0.03
    }
    if cell.toDoItem === toDoItem {
      startAnimating = true
      cell.hidden = true
    }
  }
 
  // 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 code above is pretty simple. It iterates over the visible cells until it reaches the one that was deleted. From that point on, it applies an animation to each cell. The animation block moves each cell up by the height of one row, with a delay that increases with each iteration.

The effect that is produced is shown in the animated gif below – the original version of this app had a problem with the green completed items flickering if you deleted an item above them, so this gif shows that this doesn’t happen now:

DeleteAnimation

That’s pretty groovy, right?

Just a note about reloadData: You might have noticed in the code above that when the animation for the very last cell completes, it calls reloadData on the UITableView. Why is this?

As mentioned previously, UITableView manages the cell lifecycle and position where cells are rendered onscreen. Moving the location of the cells, as you have done here with the delete animation, is something that the UITableView was not designed to accommodate.

If you remove the call to reloadData, delete an item, then scroll the list, you will find that the UI becomes quite unstable, with cells appearing and disappearing unexpectedly.

By sending the reloadData message to the UITableView, this issue is resolved. reloadData forces the UITableView to “dispose” of all of the cells and re-query the datasource. As a result, the cells are all located where the UITableView expects them to be.

Editing Items

Currently the to-do items are rendered using a UILabel subclass – StrikeThroughText. In order to make the items editable, you need to switch to UITextField instead.

Fortunately, this is a very easy change to make. Simply edit StrikeThroughText.swift and, in its opening class line change the superclass from UILabel to UITextField:

class StrikeThroughText: UITextField {

Unfortunately, UITextField is a little dumb, and hitting Return (or Enter) does not close the keyboard. So you have to do a bit more work here if you don’t want to be stuck with a keyboard over half of your nice, snazzy UI. :]

Switch to TableViewCell.swift and change its opening class line as follows:

class TableViewCell: UITableViewCell, UITextFieldDelegate {

Since TableViewCell contains the StrikeThroughText instance, you set it to conform to the UITextFieldDelegate protocol so that the text field notifies the table cell when the user taps Return on the keyboard. (Because StrikeThroughText is now a subclass of UITextField, it contains a delegate property that expects a class that conforms to UITextFieldDelegate.)

Still in TableViewCell.swift, add the following code to the init method, right after the call to super.init:

label.delegate = self
label.contentVerticalAlignment = .Center

The above code sets up the label’s delegate to be the TableViewCell instance. It also sets the control to center vertically within the cell. If you omit the second line, you’ll notice that the text now displays aligned to the top of each row. That just doesn’t look right. :]

Now all you need to do is implement the relevant UITextFieldDelegate methods. Add the following code:

// MARK: - UITextFieldDelegate methods
 
func textFieldShouldReturn(textField: UITextField!) -> Bool {
  // close the keyboard on Enter
  textField.resignFirstResponder()
  return false
}
 
func textFieldShouldBeginEditing(textField: UITextField!) -> Bool {
  // disable editing of completed to-do items
  if toDoItem != nil {
    return !toDoItem!.completed
  }
  return false
}
 
func textFieldDidEndEditing(textField: UITextField!) {
  if toDoItem != nil {
    toDoItem!.text = textField.text
  }
}

The above code is pretty self-explanatory, since all it does is close the keyboard when the user taps Enter, not allow the cell to be edited if the item has already been completed, and set the to-do item text once the editing completes.

Build, run, and enjoy the editing experience!

EditingItems

Note: If the Simulator is using your Mac’s keyboard instead of displaying an iPhone keyboard, select Hardware\Keyboard\Toggle Software Keyboard or press Command-K to bring up the software keyboard.

After a little bit of testing, you will probably notice one small issue. If you edit an item that is in the bottom half of the screen (or less than half for you lucky iPhone 5 or 6 owners!), when the keyboard appears, it covers the item you are editing.

This does not lead to a good user experience. The easiest way to fix this behavior is to scroll the cell being edited to the top of the list. Unfortunately, for cells at the very bottom simply setting the table’s contentOffset won’t work, as the table will always keep some cells behind the keyboard. Instead, you’ll mimic a table scroll with a translation transform on all the visible cells. But first, you’ll need the ViewController to know about the edit lifecycle. The edit lifecycle is currently only visible to the TableViewCell, but you can expose it via its protocol.

Open ViewController.swift and add this MARK group above the toDoItemDeleted method:

// MARK: - TableViewCellDelegate methods

Then add two empty methods below the toDoItemDeleted method:

func cellDidBeginEditing(editingCell: TableViewCell) {
 
}
 
func cellDidEndEditing(editingCell: TableViewCell) {
 
}

These will become TableViewCellDelegate editing lifecycle methods: the first will move the visible rows so that editingCell is at the top, while making the other rows more transparent; the second will move the rows back, restoring the other rows to totally opaque. Open TableViewCell.swift and declare these two methods in the protocol TableViewCellDelegate block:

// Indicates that the edit process has begun for the given cell
func cellDidBeginEditing(editingCell: TableViewCell)
// Indicates that the edit process has committed for the given cell
func cellDidEndEditing(editingCell: TableViewCell)

These protocol methods are simply invoked when the relevant UITextFieldDelegate method is invoked in TableViewCell.swift. Add the UITextFieldDelegate method textFieldDidBeginEditing to TableViewCell.swift:

func textFieldDidBeginEditing(textField: UITextField!) {
  if delegate != nil {
    delegate!.cellDidBeginEditing(self)
  }
}

And in textFieldDidEndEditing, add a call to the cellDidEndEditing delegate method:

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

At this point, it doesn’t matter whether you call cellDidEndEditing before, or after, setting the to-do item’s text property but, later in this tutorial, it might…

Now, add implementations for the new TableViewCellDelegate editing lifecycle methods:

func cellDidBeginEditing(editingCell: TableViewCell) {
  var editingOffset = tableView.contentOffset.y - editingCell.frame.origin.y as CGFloat
  let visibleCells = tableView.visibleCells() as [TableViewCell]
  for cell in visibleCells {
    UIView.animateWithDuration(0.3, animations: {() in
      cell.transform = CGAffineTransformMakeTranslation(0, editingOffset)
      if cell !== editingCell {
        cell.alpha = 0.3
      }
    })
  }
}
 
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
      }
    })
  }
}

The above code animates the frame of every cell in the list in order to push the cell being edited to the top. The alpha is also reduced for all the cells other than the one being edited.

In some parts of this tutorial series, you move cells by changing their frame with CGRectOffset, whereas in the above code, you apply a transform instead. Using a transform has the big advantage that it is easy to move a cell back to its original location: you simply “zero” the translation (i.e., apply the identity), instead of having to store the original frame for each and every cell that is moved.

Build, run, and rejoice!

As a user starts editing an item, it is gracefully animated to the top of the screen. When the user hits Enter, the item gracefully slides back into place.

BetterEditMode

There is one glaring omission in the app’s functionality – the user cannot add new items to the list! Of course, I’m not sure that’s such a bad thing – I hate adding new to-dos to my never-ending list. :]

A conventional approach to this problem would most likely be to add a button with the text “Add new” on a title bar. But remember to ask yourself every time you want to add a new UI control: can I perform the same function via a gesture?

I’m guessing that you know the answer in this case, as in most cases, is YES!

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

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

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

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

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.

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

Where To Go From Here?

Here’s the finished project with all of the code for the completed app.

I hope you have enjoyed this tutorial and are inspired to think about how to make better use of gestures in your own apps. Resist the urge to rely on buttons, sliders, and other tired old user interface metaphors. Think about how you can allow your users to interact using natural gestures.

To my mind the key word here is natural. All of the gestures that you have added to this to-do list feel natural, because they result in a user interface that reacts to your touch in much the same way that real objects do. This is one of the most compelling features of a touch-based interface!

If you do use gestures, bear in mind that they might not be as discoverable as a more blatant “Click to Add New” button. Think about how you can improve their discoverability via contextual cues.

In this example, the cues have all been visual, but they don’t have to be! Why not try using sounds or vibration? But please, do so in moderation.

If you want to develop this to-do app further, why not try adding a reorder function, where a tap-and-hold gesture floats an item above the list, allowing it to be dragged around. Again, think about the physics of this interaction. The item being dragged should appear larger and cast a shadow over the other items in the list.

Enjoy creating gesture-driven interfaces, and please share you stories and successes in the forum discussion below! :]

Making a Gesture-Driven To-Do List App Like Clear in Swift: Part 2/2 is a post from: Ray Wenderlich

The post Making a Gesture-Driven To-Do List App Like Clear in Swift: Part 2/2 appeared first on Ray Wenderlich.


Viewing all articles
Browse latest Browse all 4370

Trending Articles



<script src="https://jsc.adskeeper.com/r/s/rssing.com.1596347.js" async> </script>