How to Make a Gesture-Driven To-Do List App Like Clear: Part 3/3

This is a post by Tutorial Team Member Colin Eberhardt, CTO of ShinobiControls, creators of playful and powerful iOS controls. Check out their app, ShinobiPlay. You can find Colin on Google+ and Twitter This is the last in a three-part tutorial series that walks you through creating a simple to-do list app free of buttons, […] By Colin Eberhardt.

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

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. 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 make use of UIPinchGestureRecognizer.

Add a new file to the project using the iOS\Cocoa Touch\Objective-C class template. Name it SHCTableViewPinchToAdd and make it a subclass of NSObject.

You’ll follow exactly the same pattern as with the refactored pull-to-add. Open SHCTableViewPinchToAdd.h and replace its contents with the following:

#import "SHCTableView.h"

// A behavior that adds the facility to pinch the list in order to insert a new
// item at any location.
@interface SHCTableViewPinchToAdd : NSObject

// associates this behavior with the given table
-(id)initWithTableView:(SHCTableView*)tableView;

@end

You’ll use an SHCTableViewCell as a placeholder once again, and create the gesture recognizer within the init method. Open SHCTableViewPinchToAdd.m and replace its contents with the following:

#import "SHCTableViewPinchToAdd.h"
#import "SHCTableViewCell.h"

@implementation SHCTableViewPinchToAdd {
    // the table that this class extends and adds behavior to
    SHCTableView* _tableView;
    // a cell which is rendered as a placeholder to indicate where a new item is added
    SHCTableViewCell* _placeholderCell;
}

-(id)initWithTableView:(SHCTableView*)tableView {
    self = [super init];
    if (self) {
        _placeholderCell = [[SHCTableViewCell alloc] init];
        _placeholderCell.backgroundColor = [UIColor redColor];
        _tableView = tableView;
        // add a pinch recognizer
        UIGestureRecognizer* recognizer = [[UIPinchGestureRecognizer alloc] initWithTarget:self action:@selector(handlePinch:)];
        [_tableView addGestureRecognizer:recognizer];
    }
    return self;
}

-(void)handlePinch:(UIPinchGestureRecognizer *)recognizer {
    //TODO: Clever code goes here!
}

@end

The handlePinch method is called when a pinch gesture starts, changes (i.e., the user moves their finger), and ends. In order to allow the user to “part” the list, you need to detect when their fingers touch two neighboring to-do items before executing a pinch gesture.

Your first task is to detect the start of the pinch. Replace the empty handlePinch: implementation with the following code:

-(void)handlePinch:(UIPinchGestureRecognizer *)recognizer {
    if (recognizer.state == UIGestureRecognizerStateBegan) {
        [self pinchStarted:recognizer];
    }    
}

-(void) pinchStarted:(UIPinchGestureRecognizer *)recognizer {
    // ...
}

Before tackling the problem of detecting which to-do items were touched and whether they are neighbors, you’ll add a few utility methods. This class needs to pass pairs of touch points between methods, so it makes sense to create a structure in order to pass this information around more easily.

Add the following to SHCTableViewPinchToAdd.m before the @implementation line:

// represents the upper and lower points of a pinch
struct SHCTouchPoints {
    CGPoint upper;
    CGPoint lower;
};
typedef struct SHCTouchPoints SHCTouchPoints;

When the user executes a pinch, it doesn’t really matter which of the touch points is the upper or lower point. The points are simply named upper and lower for ease of reference.

You also need to consider the scroll offset when handling touch events. Add the following helper method to tackle this:

// returns the two touch points, ordering them to ensure that upper and lower
// are correctly identified.
-(SHCTouchPoints) getNormalisedTouchPoints: (UIGestureRecognizer*) recognizer {
    CGPoint pointOne = [recognizer locationOfTouch:0 inView:_tableView];
    CGPoint pointTwo = [recognizer locationOfTouch:1 inView:_tableView];
    // offset based on scroll
    pointOne.y += _tableView.scrollView.contentOffset.y;
    pointTwo.y += _tableView.scrollView.contentOffset.y;
    // ensure pointOne is the top-most
    if (pointOne.y > pointTwo.y) {
        CGPoint temp = pointOne;
        pointOne = pointTwo;
        pointTwo = temp;
    }
    SHCTouchPoints points = {pointOne, pointTwo};
    return points;
}

The final utility method you need is one that 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-direction.

Add the following method:

-(BOOL) viewContainsPoint:(UIView*)view withPoint:(CGPoint)point {
    CGRect frame = view.frame;
    return (frame.origin.y < point.y) && (frame.origin.y + frame.size.height) > point.y;
}

These utility methods help you locate the cells that are touched by the user and determine whether they are neighbors. You can now fill in the details for pinchStarted:. But before doing that, you need a few new instance variables. Add them at the top of the file:

    // the indices of the upper and lower cells that are being pinched
    int _pointOneCellindex;
    int _pointTwoCellindex;
    
    // the location of the touch points when the pinch began
    SHCTouchPoints _initialTouchPoints;
    
    // indicates that the pinch is in progress
    BOOL _pinchInProgress;
    
    // indicates that the pinch was big enough to cause a new item to be added
    BOOL _pinchExceededRequiredDistance;

Now replace the empty implementation for pinchStarted: with this one:

-(void) pinchStarted:(UIPinchGestureRecognizer *)recognizer {
    // find the touch-points
    _initialTouchPoints = [self getNormalisedTouchPoints:recognizer];
    
    // locate the cells that these points touch
    _pointOneCellindex = -100;
    _pointTwoCellindex = -100;
    NSArray* visibleCells = _tableView.visibleCells;
    for (int i=0; i < visibleCells.count; i++) {
        UIView* cell = (UIView*)visibleCells[i];
        if ([self viewContainsPoint:cell withPoint:_initialTouchPoints.upper]) {
            _pointOneCellindex = i;
            // highlight the cell – just for debugging!
            cell.backgroundColor = [UIColor purpleColor];
        }
        if ([self viewContainsPoint:cell withPoint:_initialTouchPoints.lower]) {
            _pointTwoCellindex = i;
            // highlight the cell – just for debugging!
            cell.backgroundColor = [UIColor purpleColor];
        }
    }
    
    // check whether they are neighbors
    if (abs(_pointOneCellindex - _pointTwoCellindex) == 1) {
        // if so - initiate the pinch
        _pinchInProgress = YES;
        _pinchExceededRequiredDistance = NO;
        
        // show your place-holder cell
        SHCTableViewCell* precedingCell = (SHCTableViewCell*)(_tableView.visibleCells)[_pointOneCellindex];
        _placeholderCell.frame = CGRectOffset(precedingCell.frame, 0.0f, SHC_ROW_HEIGHT / 2.0f);
        [_tableView.scrollView 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, then the app displays the cell placeholder that shows it will insert the new cell.

This new interaction can be added with just a couple of lines of code. Open SHCViewController.m and add the new class as an instance variable. Also remember to import the header:

// Import header
#import "SHCTableViewPinchToAdd.h"

// Add instance variable under the @implementation section
SHCTableViewPinchToAdd* _pinchAddNew;

Then create an instance of this new interaction at the end of viewDidLoad::

    _pinchAddNew = [[SHCTableViewPinchToAdd alloc] initWithTableView:self.tableView];

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:

Note: While it is possible to test the app on the Simulator, you might find it easier to test this part on 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 cells is as simple as changing the SHC_ROW_HEIGHT define.

Note: While it is possible to test the app on the Simulator, you might find it easier to test this part on 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 cells is as simple as changing the SHC_ROW_HEIGHT define.

The next step is to handle the pinch and part the list. Open SHCTableViewPinchToAdd.m and add the following to the end of handlePinch::

    if (recognizer.state == UIGestureRecognizerStateChanged
        && _pinchInProgress
        && recognizer.numberOfTouches == 2) {
        [self pinchChanged:recognizer];
    }

This code checks the _pinchInProgress instance variable that was set to YES in pinchStarted:. So this method will be executed only if the touch points are on two neighboring items, since otherwise _pinchInProgress would not be set to YES.

Now add the pinchChanged: method that the above code depends upon:

-(void)pinchChanged:(UIPinchGestureRecognizer *)recognizer {
    // find the touch points
    SHCTouchPoints currentTouchPoints = [self getNormalisedTouchPoints:recognizer];
    
    // determine by how much each touch point has changed, and take the minimum delta
    float upperDelta = currentTouchPoints.upper.y - _initialTouchPoints.upper.y;
    float lowerDelta = _initialTouchPoints.lower.y - currentTouchPoints.lower.y;
    float delta = -MIN(0, MIN(upperDelta, lowerDelta));
        
    // offset the cells, negative for the cells above, positive for those below
    NSArray* visibleCells = _tableView.visibleCells;
    for (int i=0; i < visibleCells.count; i++) {
        UIView* cell = (UIView*)visibleCells[i];
        if (i <= _pointOneCellindex) {
            cell.transform = CGAffineTransformMakeTranslation(0,  -delta);
        }
        if (i >= _pointTwoCellindex) {
            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.

In the first and second parts of this tutorial series, you moved cells by changing their frame, 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 have fun parting the list!

As the list parts, you want to scale the placeholder 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
    float gapSize = delta * 2;
    float cappedGapSize = MIN(gapSize, SHC_ROW_HEIGHT);
    _placeholderCell.transform = CGAffineTransformMakeScale(1.0f, cappedGapSize / SHC_ROW_HEIGHT );
    
    _placeholderCell.label.text = gapSize > SHC_ROW_HEIGHT ?
              @"Release to Add Item" : @"Pull to Add Item";
    
    _placeholderCell.alpha = MIN(1.0f, gapSize / SHC_ROW_HEIGHT);
    
    // determine whether they have pinched far enough
    _pinchExceededRequiredDistance = gapSize > SHC_ROW_HEIGHT;

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

You can probably turn off that purple highlight now. :]

You might have noticed the instance variable, 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, you need to add a new item to the list.

But before finishing the gesture code, you need to extend the table datasource to allow insertion of items at any index. Open SHCTableViewDataSource.h and add the following method prototype:

// Informs the datasource that a new item has been added at the given index
-(void) itemAddedAtIndex:(NSInteger)index;

The application view controller already has an implementation for itemAdded, which adds an item at the top of the list. This implementation can be repurposed to use the same logic for inserting an item mid-list.

Open SHCViewController.m and replace itemAdded with the following:

-(void)itemAdded {
    [self itemAddedAtIndex:0];
}

-(void)itemAddedAtIndex:(NSInteger)index {
    // create the new item
    SHCToDoItem* toDoItem = [[SHCToDoItem alloc] init];
    [_toDoItems insertObject:toDoItem atIndex:index];
    
    // refresh the table
    [_tableView reloadData];
    
    // enter edit mode
    SHCTableViewCell* editCell;
    for (SHCTableViewCell* cell in _tableView.visibleCells) {
        if (cell.todoItem == toDoItem) {
            editCell = cell;
            break;
        }
    }
    [editCell.label becomeFirstResponder];
}

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

Now, back to the interaction logic! Open SHCTableViewPinchToAdd.m and add the following code to the end of handlePinch: to handle the end of the pinch:

    if (recognizer.state == UIGestureRecognizerStateEnded) {
        [self pinchEnded:recognizer];
    }

Now add the following method:

-(void)pinchEnded:(UIPinchGestureRecognizer *)recognizer {
    _pinchInProgress = false;
    
    // remove the placeholder cell
    _placeholderCell.transform = CGAffineTransformIdentity;
    [_placeholderCell removeFromSuperview];
    
    if (_pinchExceededRequiredDistance) {
        // add a new item
        int indexOffset = floor(_tableView.scrollView.contentOffset.y / SHC_ROW_HEIGHT);
        [_tableView.dataSource itemAddedAtIndex:_pointTwoCellindex + indexOffset];
    } else {
        // Otherwise animate back to position
        [UIView animateWithDuration:0.2f
                              delay:0.0f
                            options:UIViewAnimationOptionCurveEaseInOut
                         animations:^{
                             NSArray* visibleCells = _tableView.visibleCells;
                             for(SHCTableViewCell* 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, the datasource method you just added 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.

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

Colin Eberhardt

Contributors

Colin Eberhardt

Author

Over 300 content creators. Join our team.