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

Colin Eberhardt
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!

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 and Twitter

This is the second in a three-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 gesture 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.

Presently, the code for animating the deletion of a to-do item is as follows (the method is in SHCViewController.m):

-(void)toDoItemDeleted:(id)todoItem {
    // use the UITableView to animate the removal of this row
    NSUInteger index = [_toDoItems indexOfObject:todoItem];
    [self.tableView beginUpdates];
    [_toDoItems removeObject:todoItem];
    [self.tableView deleteRowsAtIndexPaths:@[[NSIndexPath indexPathForRow:index inSection:0]]
                          withRowAnimation:UITableViewRowAnimationFade];
    [self.tableView endUpdates];    
}

This uses the “stock” UITableViewRowAnimationFade 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. Replace the current todoItemDeleted: implementation with the following:

-(void) toDoItemDeleted:(SHCToDoItem*)todoItem {
    float delay = 0.0;
 
    // remove the model object
    [_toDoItems removeObject:todoItem];
 
    // find the visible cells
    NSArray* visibleCells = [self.tableView visibleCells];
 
    UIView* lastView = [visibleCells lastObject];
    bool startAnimating = false;
 
    // iterate over all of the cells
    for(SHCTableViewCell* cell in visibleCells) {
        if (startAnimating) {
            [UIView animateWithDuration:0.3 
                                  delay:delay 
                                options:UIViewAnimationOptionCurveEaseInOut
                             animations:^{
                                 cell.frame = CGRectOffset(cell.frame, 0.0f, -cell.frame.size.height);
                             }
                             completion:^(BOOL finished){
                                 if (cell == lastView) {
                                     [self.tableView reloadData];
                                 }
                             }];
            delay+=0.03;
        }
 
        // if you have reached the item that was deleted, start animating
        if (cell.todoItem == todoItem) {
            startAnimating = true;
            cell.hidden = YES;
        }
    }
}

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 screenshots below:

That’s pretty groovy, right?

Unfortunately, there is one little problem. 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.

Unfortunately, it’s not that simple. The issue is almost resolved, but not completely. If you mark a few items as complete, then delete an item above the complete ones, you will probably notice an odd flickering effect as the green highlight rapidly shifts between cells.

Where is this strange effect coming from? It is hard to say exactly, but my thought is that the cells that are recycled are being briefly rendered before their state is updated to reflect their new status, as dictated by the cellForRowAtIndex method that you implemented in Part 1 of this tutorial.

I have tried various other hacks, and even hacks upon hacks, but unfortunately there doesn’t seem to be any way to remove this UI glitch. The bottom line is that the UITableView doesn’t like you messing with the location of its cells.

So, what next? Give up? No way!

Image Credit: quickmeme.com

Creating a Custom Table View

There’s only one thing to do: throw out UITableView and start from scratch. :] Sounds extreme? You’ll be pleasantly surprised – it really isn’t that hard and is a great learning experience.

Start by adding a new class to the project using the iOS\Cocoa Touch\Objective-C class template. Name the class SHCTableView, and make it a subclass of UIView.

Your table implementation will have to render cells within a scrolling container, so switch to SHCTableView.m and replace the existing code with the following:

#import "SHCTableView.h"
 
@implementation SHCTableView {
    // the scroll view that hosts the cells
    UIScrollView* _scrollView;    
}
 
-(id)initWithCoder:(NSCoder *)aDecoder {
    self = [super initWithCoder:aDecoder];
    if (self) {
        _scrollView = [[UIScrollView alloc] initWithFrame:CGRectNull];
        [self addSubview:_scrollView];
        _scrollView.backgroundColor = [UIColor clearColor];
        self.backgroundColor = [UIColor clearColor];
 
    }
    return self;
}
 
-(void)layoutSubviews {
    _scrollView.frame = self.frame;
    [self refreshView];
}
 
@end

The above code simply adds a UIScrollView to the view and sets it up on initialization. The code still needs the refreshView method referenced in layoutSubviews to be implemented, but you’ll get to that soon.

You want this table to operate in a similar manner to UITableView, but better – that is, you want a bit more control over the cell lifecycle. So add a new protocol to the project using the iOS\Cocoa Touch\Objective-C protocol template. Name it SHCTableViewDataSource. This protocol mimics UITableViewDataSource, so replace the existing code in SHCTableViewDataSource.h with the following:

// The SHCTableViewDataSource is adopted by a class that is a source of data
// for a SHCTableView
@protocol SHCTableViewDataSource <NSObject>
 
// Indicates the number of rows in the table
-(NSInteger)numberOfRows;
 
// Obtains the cell for the given row
-(UIView *)cellForRow:(NSInteger)row;
 
@end

As you will notice from the interface above, you have simplified things a little by removing the sections concept – your better, improved table will only have rows. There are no pesky sections to worry about. :]

The table needs to expose a property so that the datasource can be set. In order to reference the new protocol, first add an import for it to SHCTableView.h:

#import "SHCTableViewDataSource.h"

Now add the property to SHCTableView.h:

// the object that acts as the data source for this table
@property (nonatomic, assign) id<SHCTableViewDataSource> dataSource;

You’ll go for a very simple table implementation at first, when the datasource property is set, the scrollview height is set and cells created.

Add the following code to SHCTableView.m:

const float SHC_ROW_HEIGHT = 50.0f;
 
-(void)refreshView {
    // set the scrollview height
    _scrollView.contentSize = CGSizeMake(_scrollView.bounds.size.width,
                                         [_dataSource numberOfRows] * SHC_ROW_HEIGHT);
 
    // add the cells
    for (int row=0; row < [_dataSource numberOfRows]; row++) {
        // obtain a cell
        UIView* cell = [_dataSource cellForRow:row];
        // set its location
        float topEdgeForRow = row * SHC_ROW_HEIGHT;
        CGRect frame = CGRectMake(0, topEdgeForRow,
                                  _scrollView.frame.size.width, SHC_ROW_HEIGHT);
        cell.frame = frame;
        // add to the view
        [_scrollView addSubview:cell];
    }
}
 
#pragma mark - property setters
-(void)setDataSource:(id<SHCTableViewDataSource>)dataSource {
    _dataSource = dataSource;
    [self refreshView];
}

Again, the above code simplifies things: for example, the cell height is hard-coded at 50 pixels. However, the primary division of responsibilities remains the same as in UITableView – the datasource indicates the number of rows and supplies a factory method for supplying each row, whereas the table takes care of cell lifecycle and the positioning of each cell within a scrolling container.

That’s all the code you need in order to render a very basic table, so now it’s time to put it into action by modifying the view controller to use it!

The first step is to replace the existing UITableView in the nib file with your new implementation. Switch to SHCViewController.xib and remove the old table view instance from the view.

Next add a a custom control to the view controller by:

  1. Dragging a view control onto the main view.
  2. Setting the custom class for the new view to SHCTabelView using the Identity Inspector, as illustrated below:
  3. Finally, with the Assistant Editor open, Control-drag from the new view to the header file to create an outlet. Name the outlet tableView, as before. Also, don’t forget to remove the old UITableView outlet property from the header file.

In order to make the view controller work with the new table interface, there are a few other changes that need to be made. First, add an import to the top of SHCViewController.h:

#import "SHCTableView.h"

Next, you should remove the UITableView datasource and delegate protocols from SHCViewController.h, replacing them with the newly-defined datasource so that the @interface line looks like this:

@interface SHCViewController : UIViewController <SHCTableViewCellDelegate, SHCTableViewDataSource>

Next, update viewDidLoad in SHCViewController.m to configure the new table view:

-(void)viewDidLoad {
    [super viewDidLoad];
    self.tableView.dataSource = self;
    self.tableView.backgroundColor = [UIColor blackColor];
}

For the time being, the toDoItemDeleted: method you added at the beginning of this tutorial will not work, so comment it out – you’ll fix that later.

Finally, the UITableViewDataSource methods should be replaced with the following similar implementation of the SHCTableViewDataSource protocol:

#pragma mark - SHCTableViewDataSource methods
-(NSInteger)numberOfRows {
    return _toDoItems.count;
}
 
-(UITableViewCell *)cellForRow:(NSInteger)row {
    NSString *ident = @"cell";   
    SHCTableViewCell *cell = [[SHCTableViewCell alloc] initWithStyle:UITableViewCellStyleValue1 reuseIdentifier:ident];
    SHCToDoItem *item = _toDoItems[row];
    cell.todoItem = item;        
    cell.delegate = self;
    cell.backgroundColor = [self colorForIndex:row];
    return cell;
}

Build and run your code – and there you have it:

Wow – time to celebrate! Well… almost.

The current table implementation is, unfortunately, a little too simplistic.

One of the primary functions that UITableView performs is the pooling/recycling of cell instances. On mobile devices, memory is scarce, and the most memory-hungry part of an app tends to be the user interface. Creating a table view implementation that makes all of the off-screen cells is not an efficient solution.

It’s time to fix that!

Recycling Cells

First, you need a structure for your table to store cells for reuse, so add the following instance variable to SHCTableView.m (below the existing one for _scrollView):

    // a set of cells that are reuseable
    NSMutableSet* _reuseCells;

Next, initialize the NSMutableSet you added above at the end of initWithCoder::

        _reuseCells = [[NSMutableSet alloc] init];

Now, you need to make refreshView a little more intelligent. For instance, cells that have scrolled off-screen need to be placed into the reuse pool, _reuseCells. Also, when gaps appear, you need to fill them with new cells, either from the pool or obtained via the datasource.

Add this logic by replacing the current implementation of refreshView with the following:

// based on the current scroll location, recycles off-screen cells and
// creates new ones to fill the empty space.
-(void) refreshView {
    if (CGRectIsNull(_scrollView.frame)) {
        return;
    }
    // set the scrollview height
    _scrollView.contentSize = CGSizeMake(_scrollView.bounds.size.width,
                                         [_dataSource numberOfRows] * SHC_ROW_HEIGHT);
    // remove cells that are no longer visible
    for (UIView* cell in [self cellSubviews]) {
        // is the cell off the top of the scrollview?
        if (cell.frame.origin.y + cell.frame.size.height < _scrollView.contentOffset.y) {
            [self recycleCell:cell];
        }
        // is the cell off the bottom of the scrollview?
        if (cell.frame.origin.y > _scrollView.contentOffset.y + _scrollView.frame.size.height) {
            [self recycleCell:cell];
        }
    }
 
    // ensure you have a cell for each row
    int firstVisibleIndex = MAX(0, floor(_scrollView.contentOffset.y / SHC_ROW_HEIGHT));
    int lastVisibleIndex = MIN([_dataSource numberOfRows],
                               firstVisibleIndex + 1 + ceil(_scrollView.frame.size.height / SHC_ROW_HEIGHT));
    for (int row = firstVisibleIndex; row < lastVisibleIndex; row++) {
        UIView* cell = [self cellForRow:row];
        if (!cell) {
            // create a new cell and add to the scrollview
            UIView* cell = [_dataSource cellForRow:row];
            float topEdgeForRow = row * SHC_ROW_HEIGHT;
            cell.frame = CGRectMake(0, topEdgeForRow, _scrollView.frame.size.width, SHC_ROW_HEIGHT);
            [_scrollView insertSubview:cell atIndex:0];
        }
    }
}
 
// recycles a cell by adding it the set of reuse cells and removing it from the view
-(void) recycleCell:(UIView*)cell {
    [_reuseCells addObject:cell];
    [cell removeFromSuperview];
}
 
// returns the cell for the given row, or nil if it doesn't exist
-(UIView*) cellForRow:(NSInteger)row {
    float topEdgeForRow = row * SHC_ROW_HEIGHT;
    for (UIView* cell in [self cellSubviews]) {
        if (cell.frame.origin.y == topEdgeForRow) {
            return cell;
        }
    }
    return nil;
}

The two phases, remove off-screen cells, and add new cells to fill gaps, should be apparent in refreshView. The method also makes use of an interesting support method, cellSubViews, which returns an array of cells from the scroll view.

Implement that method by adding the following code:

// the scrollView subviews that are cells
-(NSArray*)cellSubviews {
    NSMutableArray* cells = [[NSMutableArray alloc] init];
    for (UIView* subView in _scrollView.subviews) {
        if ([subView isKindOfClass:[SHCTableViewCell class]]) {
            [cells addObject:subView];
        }
    }
    return cells;
}

The subViews property of a UIScrollView not only exposes the views that you have added to it yourself, but it also returns the two UIImageView instances that render the scroll indicators.

This can be a bit confusing! (See the StackOverflow question on UIScrollView Phantom Subviews). That’s why you check for the subview class before adding it to the list of cells to be returned – so as to make sure that you only return the SHCTableViewCell instances.

Since the above method references SHCTableViewCell, add the following import to the top of the file:

#import "SHCTableViewCell.h"

The code above does a great job of recycling cells, but it wouldn’t be of any use without providing a mechanism to allow the datasource to pull items out of the pool!

It’s time to make a few more changes to the table view interface in SHCTableView.h. Add the following method prototypes:

// dequeues a cell that can be reused
-(UIView*)dequeueReusableCell;
 
// registers a class for use as new cells
-(void)registerClassForCells:(Class)cellClass;

Rather than adopt the old (pre-iOS 6) UITableView approach of recycling cells – where you have to ask for a cell, check whether it is nil, then create a new instance of cell – the above sets it up as it works post-iOS 6. You simply register a Class which defines the cell type. The table can then perform the nil check internally, and the method always returns a cell, either from the reuse pool or by creating a new one.

Before implementing the above methods, you need to add a supporting instance variable to SHCTableView.m (right below _reuseCells):

    // the Class which indicates the cell type
    Class _cellClass;

Now add the method implementations:

-(void)registerClassForCells:(Class)cellClass {
    _cellClass = cellClass;
}
 
-(UIView*)dequeueReusableCell {
    // first obtain a cell from the reuse pool
    UIView* cell = [_reuseCells anyObject];
    if (cell) {
        NSLog(@"Returning a cell from the pool");
        [_reuseCells removeObject:cell];
    }
    // otherwise create a new cell
    if (!cell) {
        NSLog(@"Creating a new cell");
        cell = [[_cellClass alloc] init];
    }
    return cell;
}

And now the final steps – making use of the cell reuse functionality!

Switch to SHCViewController.m and add the following to the end of viewDidLoad to specify the cell type:

    [self.tableView registerClassForCells:[SHCTableViewCell class]];

And update cellForRow: by replacing this line:

    SHCTableViewCell *cell = [[SHCTableViewCell alloc] initWithStyle:UITableViewCellStyleValue1 reuseIdentifier:ident];

With the following (you can also remove the unnecessary ident variable, since it’s no longer needed):

    SHCTableViewCell* cell = (SHCTableViewCell*)[self.tableView dequeueReusableCell];

If you build and run the code, you will find that your app looks exactly the same as before:

Or does it? Try scrolling. Notice that you don’t see all of your to-do items! Most of the time, you’ll probably see one or two items beyond the edge of the screen, but that’s it. What is going on?

This is because the new code displays only the cells that are in view. When you scroll up or down, you need to know about the scrolling, so that the view can refresh the display. This part is fairly easy to implement. :]

First, set SHCTableView up as a UIScrollViewDelegate in SHCTableView.h:

@interface SHCTableView: UIView <UIScrollViewDelegate>

Next, you have to set up your scroll view to use your SHCTableView as its delegate. Add the following code to initWithCoder: in SHCTableView.h, right after you create the _scrollView instance:

		_scrollView.delegate = self;

Finally, you have to respond to scrollview events, specifically when the user scrolls the scrollview. Add this code to do that:

#pragma mark - UIScrollViewDelegate handlers
-(void)scrollViewDidScroll:(UIScrollView *)scrollView {
    [self refreshView];        
}

Now build and run your app, and you should be able to scroll all the way to the end of your list:

IOS Simulator  iPhone  Retina 3 5 inch  iOS 6 0  10A403

But it still looks the same as before, doesn’t it? I know what you’re thinking: “If it looks just the same, where’s my recycling?”

Scroll the to-do list up and down while keeping an eye on your console output. You should see the NSLog messages that indicate the recycling is working properly:

When the table is first displayed, the pool is empty, hence a number of cells are created (with a couple of extra ones for you lucky iPhone 5 users!). Subsequent scroll interactions cause cells to be placed in the pool as they move off screen, and then pulled out again as space appears.

The net result is that no matter how long the list is (perhaps you are a busy person with 10,000 to-dos in your list), the application only ever allocates a small handful of cells.

Deleting Items… Again

In order to reinstate the toDoItemDeleted: method that was added at the start of this article (you did comment it out instead of deleting it, didn’t you?), the table needs to be extended to expose a visible cells method and a mechanism for re-rendering all the cells.

Open SHCTableView.h and add the following method prototypes:

// an array of cells that are currently visible, sorted from top to bottom.
-(NSArray*)visibleCells;
 
// forces the table to dispose of all the cells and re-build the table.
-(void)reloadData;

Now add the method implementations to SHCTableView.m:

-(NSArray*) visibleCells {
    NSMutableArray* cells = [[NSMutableArray alloc] init];
    for (UIView* subView in [self cellSubviews]) {
        [cells addObject:subView];
    }
    NSArray* sortedCells = [cells sortedArrayUsingComparator:^NSComparisonResult(id obj1, id obj2) {
        UIView* view1 = (UIView*)obj1;
        UIView* view2 = (UIView*)obj2;
        float result = view2.frame.origin.y - view1.frame.origin.y;
        if (result > 0.0) {
            return NSOrderedAscending;
        } else if (result < 0.0){
            return NSOrderedDescending;
        } else {
            return NSOrderedSame;
        }
    }];
    return sortedCells;
}
 
-(void)reloadData {
    // remove all subviews
    [[self cellSubviews] makeObjectsPerformSelector:@selector(removeFromSuperview)];
    [self refreshView];
}

The implementation for visibleCells is nice and simple, making use of the cellSubViews method discussed earlier that returns the children of the scroll view. The method sorts the subviews in order of appearance before returning them.

reloadData is even simpler – all of the cells are removed from their parent (i.e., the scroll view) and then refreshView is invoked.

One important thing to note is that the above code does not recycle the cells. This allows the delete method to make radical changes to the cells that are under the ownership of our table, without worrying about glitches that might occur afterwards. In your case, reloadData really does throw everything away and start again, from scratch.

Uncomment toDoItemDeleted: in SHCViewController.m, build and run, and now, enjoy a glitch-free delete experience!

Editing Items

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

Fortunately, this is a very easy change to make. Simply edit SHCStrikethroughLabel.h and change the superclass from UILabel to UITextField:

@interface SHCStrikethroughLabel : 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 SHCTableViewCell.h and change the @interface line as follows:

@interface SHCTableViewCell : UITableViewCell <UITextFieldDelegate>

Since SHCTableViewCell contains the UIStrikethroughLabel instance, you set it to support the UITextFieldDelegate protocol so that the table cell knows when Return is tapped on the keyboard. (Because UIStrikethroughLabel is now a UITextField subclass, it contains a delegate property that expects a class that supports UITextFieldDelegate.)

Next, open SHCTableViewCell.m and add the following code to initWithStyle:reuseIdentifier:, right after the creation of the SHCStrikethroughLabel instance:

_label.delegate = self;
_label.contentVerticalAlignment = UIControlContentVerticalAlignmentCenter;

The above sets up the label’s delegate to be the SHCTableViewCell 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:

#pragma mark - UITextFieldDelegate
-(BOOL)textFieldShouldReturn:(UITextField *)textField {
    // close the keyboard on enter
    [textField resignFirstResponder];
    return NO;
}
 
-(BOOL)textFieldShouldBeginEditing:(UITextField *)textField {
    // disable editing of completed to-do items
    return !self.todoItem.completed;
}
 
-(void)textFieldDidEndEditing:(UITextField *)textField {
    // set the model object state when an edit has complete
    self.todoItem.text = textField.text;
}

The above code is pretty self-explanatory, since all it does is close the keyboard when Enter is tapped, 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!

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 just less than half for you lucky iPhone 5 owners!), when the keyboard appears, it covers the item you are editing.

This does not lead to a good user experience. There’s a simple fix: scrolling the item to the top of the page when editing starts.

The edit lifecycle is currently only visible to the SHCTableViewCell. You’ll need to expose this via the protocol so that you can add some extra behavior.

Open SHCTableViewCellDelegate.h and add the following line below the #import line:

@class SHCTableViewCell;

Next, add a couple of editing lifecycle method definitions:

// Indicates that the edit process has begun for the given cell
-(void)cellDidBeginEditing:(SHCTableViewCell*)cell;
 
// Indicates that the edit process has committed for the given cell
-(void)cellDidEndEditing:(SHCTableViewCell*)cell;

These protocol methods are simply invoked when the relevant UITextFieldDelegate method is invoked in SHCTableViewCell.m. Add the following to the file:

-(void)textFieldDidEndEditing:(UITextField *)textField {
    [self.delegate cellDidEndEditing:self];
    self.todoItem.text = textField.text;
}
 
-(void)textFieldDidBeginEditing:(UITextField *)textField {
    [self.delegate cellDidBeginEditing:self];
}

Note: textFieldDidEndEditing: is already implemented, so you should replace the method with the new one. textFieldDidBeginEditing: is a new method that needs to be added to the class.

Since SHCViewController is already set as the delegate for each cell, you don’t have to set the delegate, but you do need to add a new instance variable to SHCViewController.m, as follows:

    // the offset applied to cells when entering “edit mode”
    float _editingOffset;

Next, add implementations for the new delegate methods:

-(void)cellDidBeginEditing:(SHCTableViewCell *)editingCell {
    _editingOffset = _tableView.scrollView.contentOffset.y - editingCell.frame.origin.y;
    for(SHCTableViewCell* cell in [_tableView visibleCells]) {
        [UIView animateWithDuration:0.3
                         animations:^{
                             cell.frame = CGRectOffset(cell.frame, 0, _editingOffset);
                             if (cell != editingCell) {
                                 cell.alpha = 0.3;
                             }
                         }];
    }
}
 
-(void)cellDidEndEditing:(SHCTableViewCell *)editingCell {
    for(SHCTableViewCell* cell in [_tableView visibleCells]) {
        [UIView animateWithDuration:0.3
                         animations:^{
                             cell.frame = CGRectOffset(cell.frame, 0, -_editingOffset);
                             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 editing.

The above also requires that the table view exposes its scroll view, so add a read-only property as follows to SHCTableView.h:

// the UIScrollView that hosts the table contents
@property (nonatomic, assign, readonly) UIScrollView* scrollView;

Also add the following getter to SHCTableView.m so that the existing _scrollView instance variable is returned when the scrollView property is accessed.

-(UIScrollView *)scrollView {
    return _scrollView;
}

Build, run, and rejoice!

As a user starts editing items, they are gracefully animated to the top of the screen. When the user hits Enter, the items gracefully slide back into place.

Where To Go From Here?

Here’s the example project with all of the code up to this point of the tutorial series.

I hope you have enjoyed the second installment of this three-part series!

Give yourself a big pat on the back for finishing – the custom table view implementation required quite a bit of code in order to remove the rendering glitch that was unavoidable with UITableView. However, it is interesting to see that a re-implementation of UITableView is entirely possible.

Before wrapping up this tutorial, I want to say a few things about the class design so far. For the sake of simplicity, some of the responsibilities are a bit mixed up, and in addition, the encapsulation is a poor in places.

For example, it’s a tad ugly that the view controller connects directly with the cell delegate in order to handle edit/delete operations. A much more elegant solution would be to use the table as the cell delegate, then propagate these events via a table delegate.

Another example of poor encapsulation is that the table view exposes its scroll view. It would be much better to have the table view “wrap” the scroll view, exposing only those method that are required, as explained in the Adapter Pattern.

I’ll leave that to you as an additional exercise. ;-]

The third and last installment of this tutorial series is where the real fun happens. It will be pinch and pull-down gesture time, my friends!

I look forward to hearing from you in the forums!


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.

Colin Eberhardt

Colin Eberhardt has been writing code and tutorials for many years, covering a wide range of technologies and platforms. Most recently he has turned his attention to iOS. Colin is CTO of ShinobiControls, creators of charts, grids and other powerful iOS controls.

You can check out their app, ShinobiPlay, in the App Store.

User Comments

25 Comments

[ 1 , 2 ]
  • Looks like you want to make the code more flexible by implementing:

    Code: Select all

    -(void)registerClassForCells:(Class)cellClass {
        _cellClass = cellClass;
    }


    Unfortunately you still have references to SHCTableViewCell in the import statement and in this code:

    Code: Select all

            if ([subView isKindOfClass:[SHCTableViewCell class]]) {
                [cells addObject:subView];
            }


    Would replacing the code with this work?

    Code: Select all

            if ([subView isKindOfClass:[_cellClass class]]) {
                [cells addObject:subView];
            }


    Or would that just return the "Class" class? It seems to work...
    chakatodd
  • The example set is recycling many cells unnecessarily when we scrolling the table. Is that normal?
    I think it is because lastVisibleIndex in SHCTableView are miscalculating. The code below resolves the issue:

    Code: Select all
    int lastVisibleIndex = MIN([_dataSource numberOfRows], ceil((_scrollView.contentOffset.y / SHC_ROW_HEIGHT)+(_scrollView.frame.size.height / SHC_ROW_HEIGHT)));
    alessandro_ufms
  • There is a bug when we delete the last cell. It's not reloading data. The code below in toDoItemDeleted: solves the problem:

    Code: Select all
    // if you have reached the item that was deleted, start animating
    if (cell.todoItem == todoItem) {
        startAnimating = true;
        cell.hidden = YES;
       
        if (cell == lastView)
             [self.tableView reloadData];
    }
    alessandro_ufms
  • alessandro_ufms wrote:There is a bug when we delete the last cell. It's not reloading data.


    Thanks a lot for fixing that :-)
    ColinEberhardt
  • What is the point in recreating the TableView?

    Also this custom tableView doesn't ever call [cell prepareForResue]? and when scrolling lots of entries doesn't seem as performant as the normal tableView scrolling.

    I'm implemented this technique in one of my own projects that has a custom cell (130.0f height) and performance is dreadful when lots of scrolling was done, really jerky.

    Anyone else noticed this happening?
    Bluey
  • Great tutorial, although I seem to be stuck at this portion

    Now add the property to SHCTableView.h:
    Code: Select all
    // the object that acts as the data source for this table
    @property (nonatomic, assign) id<SHCTableViewDataSource> dataSource;


    I get the warning
    Property type 'id<SHCTableViewDataSource>' is incompatible with type 'id<UITableViewDataSource>' inherited from 'UITableView'


    and in the SHCTableView.m

    Code: Select all
    const float SHC_ROW_HEIGHT = 50.0f;

    -(void)refreshView {
        // set the scrollview height
        _scrollView.contentSize = CGSizeMake(_scrollView.bounds.size.width,
                                             [_dataSource numberOfRows] * SHC_ROW_HEIGHT);

        // add the cells
        for (int row=0; row < [_dataSource numberOfRows]; row++) {
            // obtain a cell
            UIView* cell = [_dataSource cellForRow:row];
            // set its location
            float topEdgeForRow = row * SHC_ROW_HEIGHT;
            CGRect frame = CGRectMake(0, topEdgeForRow,
                                      _scrollView.frame.size.width, SHC_ROW_HEIGHT);
            cell.frame = frame;
            // add to the view
            [_scrollView addSubview:cell];
        }
    }

    #pragma mark - property setters
    -(void)setDataSource:(id<SHCTableViewDataSource>)dataSource {
        _dataSource = dataSource;
        [self refreshView];
    }


    all instances of _dataSource gives me an error
    Instance variable '_dataSource' is private


    and a bunch of other errors as well but they all seem to be caused due to the fact that _dataSource is private, which is probably related to the warning above. I've reread the tutorial to check wether I missed something but I seem to be quite stuck.
    Any help would be greatly appreciated.
    sushankrao
  • I have downloaded the fix project, but the editing part still has problem
    I keep on tapping some other texfields one after the other, the UI still unstable
    I already change
    Code: Select all
    cell.frame = CGRectOffset(cell.frame, 0, _editingOffset);

    into
    Code: Select all
    cell.transform = CGAffineTransformMakeTranslation(0,  _editingOffset);

    and
    Code: Select all
    cell.frame = CGRectOffset(cell.frame, 0, -_editingOffset);

    into
    Code: Select all
    cell.transform = CGAffineTransformIdentity;


    If I want to dismiss the keyboard when tap other textfield if I am in editing mode
    how to do that ?!
    please help me..
    JimmyHo
  • Hi Colin,

    I'm curious : in the SHCTableViewCellDelegate, why are those 2 class treated differently :

    #import "SHCToDoItem.h"
    @class SHCTableViewCell;

    I understand that generally in header files, you don't need to import the whole header file of another class, you can use @class.
    Why did you decide to #import SHCToDoItem.h, instead of @class SHCToDoItem; for example ?

    Thanks !
    Fred
    FreddyF
  • Hi,

    In the refreshView method you use
    Code: Select all
    self.scrollView.bounds.size.width
    when setting the scrollview content size, but for setting the cell frame you use
    Code: Select all
    self.scrollView.frame.size.width
    .

    How do you know when to use either one? I know that a view's bounds is a rectangle in its co ord system and that a view's frame is the position of the rectangle in the superview's co ord system. But I'm still confused as to their use in the refreshView.

    Thanks
    mrcurious
  • When you're editing a cell you can still scroll. Unfortunately, if you do that, the whole thing breaks down. I would expect that if you tap outside the keyboard it disappears. How can I do that? Thanks a lot, another great part of these tutorials by the way.

    EDIT:
    If anyone is interested how to prevent scrollView from scrolling while editing a cell, you have to add following line on completion in animation that's inside cellDidBeginEditing method.

    Code: Select all
    _tableView.scrollView.scrollEnabled = NO;


    Very similar for cellDidEndEditing:

    Code: Select all
    _tableView.scrollView.scrollEnabled = YES;


    Though it doesn't hide keyboard when you tap outside.
    emilc
[ 1 , 2 ]

Other Items of Interest

Ray's Monthly Newsletter

Sign up to receive a monthly newsletter with my favorite dev links, and receive a free epic-length tutorial as a bonus!

Advertise with Us!

Vote for Our Next Tutorial!

Every week, we alternate between Gaming and Non-Gaming tutorial votes. This week: Non-Gaming!

    Loading ... Loading ...

Last week's winner: How To Make a Tower Defense Game with Swift.

Suggest a Tutorial - Past Results

Hang Out With Us!

Every month, we have a free live Tech Talk - come hang out with us!


Coming up in December: The Great CALayer Tour

Sign Up - December

Our Books

Our Team

Tutorial Team

  • Brian Broom
  • Jean-Pierre Distler

... 59 total!

Update Team

  • Zouhair Mahieddine
  • Ray Fix

... 14 total!

Editorial Team

  • Matt Galloway

... 22 total!

Code Team

  • Orta Therox

... 3 total!

Subject Matter Experts

  • Richard Casey

... 4 total!