Tuesday, July 10, 2012

UITableView How-To: Part 6 - Inserting, Deleting, and Reordering Cells

Part 1Part 2Part 3Part 4 | Part 5

 

It seems to be time for my annual table view tutorial post.  In this long-overdue episode, we'll take a look at what is involved with making a table view support dynamic content.  If you want to allow the user to add, remove, or rearrange cells, then these are the basics that you need to understand.

 

One of the first changes to make is to use a proper data container.  If you've been using an NSArray up to this point, then you need to switch to NSMutableArray, or and/or switch NSDictionary to NSMutableDictionary.  You can add and remove items to/from the mutable versions, but cannot from non-mutable versions.

 

Create a new project using whatever template you want.  I'm using the Single View one for simplicity sake.  Place a table view on it, and get it wired up with a property.  In addition to that, place 2 buttons on the screen, one called Edit, and the other called Insert.  It doesn't need to be fancy, just something like this:

 

xib_layout.png

 

Declare IBActions for each button, and wire them up.

 

- (IBAction)insertButtonPressed:(UIButton *)button;
- (IBAction)editButtonPressed:(UIButton *)button;

 

Our initial data is just going to be a handful of strings.  Declare a property, synthesize it, then customize a getter:

 

@property (nonatomic, retain) NSMutableArray *contents;
...
@synthesize contents = ivContents; ...
- (NSMutableArray *)contents { if (ivContents == nil) { ivContents = [[NSMutableArray alloc] initWithObjects:@"A", @"B", @"C", @"D", @"E", nil]; } return ivContents; }

 

And let's quickly set up the table view delegate and data source methods.

 

- (NSInteger)tableView:(UITableView *)tableView
numberOfRowsInSection:(NSInteger)section
{
   NSInteger rows = [[self contents] count];
   return rows;
}

 

The number of rows is the count of the array.  Easy enough.

 

- (UITableViewCell *)tableView:(UITableView *)tableView
cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
   static NSString *cellIdentifier = @"cellIdentifier";
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:cellIdentifier]; if (cell == nil) { cell = [[[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:cellIdentifier] autorelease]; }
NSString *rowContent = [[self contents] objectAtIndex:[indexPath row]];
[[cell textLabel] setText:rowContent];
return cell; }

 

And then to populate the cell, we grab the string out of the array, and give it to the cell's label.  Go ahead and run the app to make sure that the table fills in correctly.  Beware that if you press either button, it will crash right now since we haven't implemented those methods yet.

 

initial_run.png

 

Insert Row

 

Let's start with inserting rows.  The UITableView method for inserting rows is insertRowsAtIndexPaths:withRowAnimation:.  First thing to note is that this has an animation property.  You can slide rows in from above, below, either side, or simply fade in.  The other thing to note is the plural - rowS - meaning that you can insert more than 1 row with this method if you want to.

 

Let's continue by implementing our insert method.  The method above deals with index paths, so the first thing we're going to do is build one.  We're making the executive decision right now that any new rows will appear at the top of the table, and this is row 0.  A handy construction method is indexPathForRow:inSection:.  Note that it has a section parameter.  We are only using a single section at the moment, so that would be section 0.  Let's begin:

 

- (IBAction)insertButtonPressed:(UIButton *)button
{
   NSInteger section = 0;
   NSInteger row = 0;
   NSIndexPath *indexPath = [NSIndexPath indexPathForRow:row inSection:section];
NSArray *indexPathsToInsert = [NSArray arrayWithObject:indexPath];
[[self mainTableView] insertRowsAtIndexPaths:indexPathsToInsert withRowAnimation:UITableViewRowAnimationRight]; }

 

We create an index path, then create an array to hold it, and finally pass that array along to the table view via the insert method.  If you run the app now, and hit the Insert button, you will again crash.  But this time there is an actual problem we need to solve.  Look in the console, and you'll find one of the most helpful error messages in all of iOS development:

 

Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Invalid update: invalid number of rows in section 0.  The number of rows contained in an existing section after the update (5) must be equal to the number of rows contained in that section before the update (5), plus or minus the number of rows inserted or deleted from that section (1 inserted, 0 deleted) and plus or minus the number of rows moved into or out of that section (0 moved in, 0 moved out).'

 

This crash message tells you exactly what went wrong, but it does take a little bit of getting to used in order to understand what it is saying.  Basically, the table is saying "Hey! I knew from before that you had 5 rows, and then you just told me to add 1, so I did that, but after I did that, there were still only 5 rows!"  Well clearly you are stupid, Mr. Table View, because you obviously should have 6 rows now, since I told you to add one.

 

Or should it?  How does the table view know how many rows should be there?  Oh, right, from this:

 

- (NSInteger)tableView:(UITableView *)tableView
numberOfRowsInSection:(NSInteger)section
{
   NSInteger rows = [[self contents] count];
   return rows;
}

 

The array still only has 5 objects.  Even though we inserted a row, the array still has just 5 objects.  The table view will ping this method again as a part of the insert process.  If 5 (old) + 1 (inserted) does not equal 6 (new total), then it will complain.  We only have a total of 5, so that's a problem.

 

This is a very important concept to understand, and it is equally important when we delete and rearrange rows in a few minutes.  Inserting a row is a view operation.  Remember the old, trusty MVC concept, Model-View-Controller?  What we did so far is make a change to V, the view.  So what still needs to happen is to make a change to M, the model.  If you insert a row in a table, you must also add something to the array.  If you remove a row from a table, you must also remove something from the array.

 

So let's revisit the insert method, and add something to the array.  Just to be quick, let's add a string that is simply a count of the number of items in the array at the time.

 

- (IBAction)insertButtonPressed:(UIButton *)button
{
   NSInteger section = 0;
   NSInteger row = 0;
   NSIndexPath *indexPath = [NSIndexPath indexPathForRow:row inSection:section];
NSString *extraContent = [NSString stringWithFormat:@"%d", [[self contents] count]]; [[self contents] insertObject:extraContent atIndex:row];
NSArray *indexPathsToInsert = [NSArray arrayWithObject:indexPath];
[[self mainTableView] insertRowsAtIndexPaths:indexPathsToInsert withRowAnimation:UITableViewRowAnimationRight]; }

 

Run the app again, and you should be able to hit the insert button as many times as you want.  Each time, a new row will appear at the top of the list, sliding in from the right.

 

after_insert.png

 

Some additional exercises for you to perform on your own:

  • Use different animation options to see how each one works.
  • Use a different row value to insert at different points in the table.  Beware that you should not use an index larger than the array.  You will need much smarter code than this is to protect against that.

 

Delete Row

 

There are 2 basic UI's for deleting rows.  There is swipe-to-delete, and then there is putting the table view into edit mode, where all of the red circles appear.  If you try to swipe on your rows right now, nothing will happen.  Swiping is not enabled by default, you have to implement a datasource method to get it.  That method is:

 

- (void)tableView:(UITableView *)tableView
commitEditingStyle:(UITableViewCellEditingStyle)editingStyle
forRowAtIndexPath:(NSIndexPath *)indexPath
{
}

 

The mere presence of this method enables the swipe-to-delete UI.  It doesn't do anything yet, but you can go ahead and run your app, swipe on a row, and you will see the delete button.  Now we need to add the guts to this method.

 

The first item to note is the editingStyle parameter.  A quick trip to the documentation reveals that the value could be none, insert, or delete.  I don't believe I have ever used this parameter for anything other than delete.  Still, better safe than sorry:

 

if (editingStyle == UITableViewCellEditingStyleDelete)

 

For inserting a row, we had to build an index path.  In this case, we are given an index path parameter already.  The table view delete row method is deleteRowsAtIndexPaths:withRowAnimation:.  Basically, the exact opposite of the insert method.  Instead of flying in from the right, the row will fly out to the right, and so on.

 

Remember from inserting a row that we must also make a change to the array.  This is no different.  We will remove a row, therefore we must remove something from the array at the same time.

 

- (void)tableView:(UITableView *)tableView
commitEditingStyle:(UITableViewCellEditingStyle)editingStyle
forRowAtIndexPath:(NSIndexPath *)indexPath
{
   if (editingStyle == UITableViewCellEditingStyleDelete)
   {
      [[self contents] removeObjectAtIndex:[indexPath row]];
NSArray *indexPathsToRemove = [NSArray arrayWithObject:indexPath];
[tableView deleteRowsAtIndexPaths:indexPathsToRemove withRowAnimation:UITableViewRowAnimationRight];} } }

 

We remove an item from the array, then delete the corresponding row.  Run the app now, and you will be able to swipe-to-delete anything you want.  If you delete everything, just hit the insert button to put more stuff back in and then go delete it again.

 

Swipe-to-delete is nice, but sometimes it is better to show all of the delete circles to let the user see that deleting can be done.  This is done by turning on the edit mode for the table view.  By default, edit mode is off, so we will need to turn it on.  This is where our other IBAction method comes in:

 

- (IBAction)editButtonPressed:(UIButton *)button
{
   [[self mainTableView] setEditing:![[self mainTableView] isEditing] animated:YES];
}

 

We look at whether or not the table view is currently in edit mode, and then set it to the opposite.  If you run the app now, you can hit the Edit button multiple times to turn on and off edit mode.  You can also go ahead and delete rows while you're there.

 

You've probably seen apps where the button says Edit sometimes, and Done sometimes.  Let's make that happen:

 

- (IBAction)editButtonPressed:(UIButton *)button
{
   [[self mainTableView] setEditing:![[self mainTableView] isEditing] animated:YES];
NSString *buttonTitle = ([[self mainTableView] isEditing]) ? @"Done" : @"Edit";
[button setTitle:buttonTitle forState:UIControlStateNormal]; }

 

For additional research that I'm not going to go into here, if you are using a navigation bar or a toolbar, you can use UIViewController's built-in editButtonItem.  As noted in the documentation, you will need to implement setEditing:animated:, and then do basically the same thing I've done here (except for the button title part).

 

Insert And Delete Rows

 

For more complicated tables, a single insert command or a single delete command may not be sufficient.  Perhaps you will need to add AND remove some rows.  Or maybe you will add rows AND add sections (there is a different method for that, see the documentation).  If you will be performing multiple actions, then you need to group those activities inside of a table animation block.

 

Drag another button onto the view, and call it "Silly".  Then implement this method:

 

- (IBAction)sillyButtonPressed:(UIButton *)button
{
   NSIndexPath *indexPath = [NSIndexPath indexPathForRow:0 inSection:0];
   NSArray *indexPaths = [NSArray arrayWithObject:indexPath];
[[self mainTableView] beginUpdates];
[[self mainTableView] insertRowsAtIndexPaths:indexPaths withRowAnimation:UITableViewRowAnimationRight]; [[self mainTableView] deleteRowsAtIndexPaths:indexPaths withRowAnimation:UITableViewRowAnimationRight];
[[self mainTableView] endUpdates]; }

 

These are the same commands we did previously, just now we are doing them at the same time.  We begin by telling the table view that we are about to give it multiple commands, via the beginUpdates method.  Then we perform whatever actions we need, and tell the table view we are done via the endUpdates method.  The table view will analyze all of these changes and come up with an animation that looks nice, performing the adds and deletes at the same time.

 

If you run the app right now, each time you hit the Silly button, the first row will slide to the right and then back to the left.

 

The 2 animations do not have to be the same.  Change one to Left, and then see what happens.

 

You may have noticed that we did not change the array.  In this (really silly) case, we didn't need to.  We add 1 row, and remove 1 row.  That means a net change of 0, so the number of items in the array is still correct.  BUT, for more complicated situations, you absolutely would need to change the array.  If you add 5 rows and delete 2 others, then your array needs to become 3 items longer.

 

Reorder Rows

 

Changing the order of items in the table is a common need.  You may have noticed that we have not yet seen the drag widgets on the right side of the table.  Just like the delete buttons, we need to implement a method in order to see them.  Also, they only show up in Edit mode, so our Edit button will be involved.  The necessary method is:

 

- (void)tableView:(UITableView *)tableView
moveRowAtIndexPath:(NSIndexPath *)fromIndexPath
toIndexPath:(NSIndexPath *)toIndexPath
{
}

 

Just like before, the mere presence of this method is enough to show the widgets.  Run the app, hit the Edit button, and you will see them.  If you try to reorder rows, it will LOOK like it works, but it really doesn't.  In order to prove that, I need to implement a different method:

 

- (void)tableView:(UITableView *)tableView
didSelectRowAtIndexPath:(NSIndexPath *)indexPath
{
   NSString *rowContent = [[self contents] objectAtIndex:[indexPath row]];
   NSLog(@"tapped row is: %@", rowContent);
}

 

Run the app, tap on a row, and then you will see this in the console.  Make sure that when you tap A, you see A in the console.  Then edit the table, drag the last row to the top, and hit the Done button.  Now tap on rows and see what happens.  When you tap on E, you should see A in the console.  Tap on A, you'll see B, and so on.

 

Why is that?  Well, it's the same reason as before.  The table view is doing the visual stuff, but the underlying data - the array - was never changed.  We need to handle that ourselves:

 

- (void)tableView:(UITableView *)tableView
moveRowAtIndexPath:(NSIndexPath *)fromIndexPath
toIndexPath:(NSIndexPath *)toIndexPath
{
   NSString *contentsToMove = [[[self contents] objectAtIndex:[fromIndexPath row]] retain];
[[self contents] removeObjectAtIndex:[fromIndexPath row]]; [[self contents] insertObject:contentsToMove atIndex:[toIndexPath row]];
[contentsToMove release]; }

 

We select the item, and retain it because we don't want it to die when we pull it out of the array.  Then we remove it, and insert it back in at the new location.  Then we release it to balance the retain.

 

Final Thoughts

 

If you read back through this tutorial, you will see that "reloadData" does not appear anywhere here.  reloadData is sort of the nuclear option, and generally speaking it is cheating.  Reloading the table also defeats any animations that might otherwise happen.  In this app right now, if you insert an item, you'll get a nice animation showing the new row.  If you delete an item, you'll get a nice animation removing the row.  The remaining rows are shifted up or down in a nice animated fashion.

 

You could go to the above code, replace each insert/delete line with a reload line, and the end result will still be a table showing the correct contents.  So it does actually "work" to do the reload.  But if you watch carefully, there will be a difference.  The new row just pops into existence.  Deleted rows just instantly disappear.  The animations are gone.  Reloading a table doesn't do any animations.  The user experience is not as good, and frankly using reloadData in these situations is Amateur Hour.  It isn't required, as obviously evidenced by the code above.  So go the extra mile, and don't use table reloading as a replacement for actually understanding how these things work.

 

UITableViewDataSource and UITableViewDelegate define a number of methods to further refine these behaviors.  Have a row that you don't want to be deleted?  There's a method for that.  Have a row that you don't want to be moved?  There's a method for that.  Want to limit where a row can be dragged?  There's a method for that, too.  All I have shown here is the bare minimum necessary to enable these features.  For more complicated behaviors, you will need to employ some or all of these other methods.  See the documentation for a full list of what is available.

 

Here is the sample project for this tutorial:

 

BTITableViewModification.zip

No comments:

Post a Comment