How To Make A Swipeable Table View Cell With Actions – Without Going Nuts With Scroll Views

So you want to make a swipeable table view cell like in Mail.app? This tutorial shows you how without getting bogged down in nested scroll views. By Ellen Shapiro.

Leave a rating/review
Save for later
Share

Make a swipeable table view cell without going nuts with scroll views!

Cookbook: Move table view cells with a long press gesture!

Apple introduced a great new user interface scheme in the iOS 7 Mail app – swiping left to reveal a menu with multiple actions. This tutorial shows you how to make such a swipeable table view cell without getting bogged down in nested scroll views. If you’re unsure what a swipeable table view cell means, then see this screenshot of Apple’s Mail.app:

Multiple Options

You’d think that after introducing something like this, Apple would have made it available to developers. After all, how much harder could it be? Unfortunately, they’ve only made the Delete button available to developers — at least for the time being. If you want to add other buttons, or change the text or color of the Delete button, you’ll have to write the whole thing yourself.

In this tutorial, you’ll learn how to implement the simple swipe-to-delete action before moving on to the swipe-to-perform-actions. This will require some digging into the structure of an iOS 7 UITableViewCell to replicate the desired behavior. You’ll use a couple of my favorite techniques for examining view hierarchies: coloring views and using the recursiveDescription method to log the view hierarchy.

Ready to see what buttons and actions are underneath those innocent-looking table view cells? Let’s get started!

Getting Started

Open Xcode, go to File\New\Project… and select a Master-Detail Application for iOS as shown below:

Master-Detail Application

Name your project SwipeableCell and fill in your own organization name and company identifier. Select iPhone as the target device and make sure the Use Core Data checkbox is unchecked, as shown below:

Set Up Project

For a proof of concept project like this, you want to keep the data model as simple as possible.

Open MasterViewController.m and find viewDidLoad. Replace the default method which sets up the navigation bar items with the following implementation:

- (void)viewDidLoad {
  [super viewDidLoad];

  //1
  _objects = [NSMutableArray array];
    
  //2
  NSInteger numberOfItems = 30;
  for (NSInteger i = 1; i <= numberOfItems; i++) {
    NSString *item = [NSString stringWithFormat:@"Item #%d", i];
    [_objects addObject:item];
  }
}

There are two things happening in this method:

  1. This line creates and initializes an instance of NSMutableArray so that you can add objects to it. If your array isn’t initialized, you can call addObject: as many times as you want, but your objects won’t be stored anywhere.
  2. This loop adds a bunch of strings to the _objects array; these are the strings displayed in the table view when your application runs. You can change the value of numberOfItems to store more or fewer strings as you see fit.

Next, find tableView:cellForRowAtIndexPath: and replace its implementation with the following:

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"Cell" forIndexPath:indexPath];

    NSString *item = _objects[indexPath.row];
    cell.textLabel.text = item;
    return cell;
}

The boilerplate tableView:cellForRowAtIndexPath: uses date strings as sample data; instead, your implementation uses the NSString objects in your array to populate the UITableViewCell’s textLabel.

Scroll down to tableView:canEditRowAtIndexPath:; you'll see that this method is already set up to return YES which means that every row of the table view supports editing.

Directly below that method, tableView:commitEditingStyle:forRowAtIndexPath: handles the deletion of objects. However, since you won’t be adding anything in this application, you'll tweak it a bit to better suit your needs.

Replace tableView:commitEditingStyle:forRowAtIndexPath: with the following code:

- (void)tableView:(UITableView *)tableView commitEditingStyle:(UITableViewCellEditingStyle)editingStyle forRowAtIndexPath:(NSIndexPath *)indexPath {
  if (editingStyle == UITableViewCellEditingStyleDelete) {
    [_objects removeObjectAtIndex:indexPath.row];
    [tableView deleteRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationFade];
  } else {
    NSLog(@"Unhandled editing style! %d", editingStyle);
  }
}

When the user deletes a row you remove the object at the index passed in, from the backing array, and tells the table view it needs to remove the row at the same indexPath to ensure the model and view both match.

Your app only allows for the "delete" editing style, but it’s a good idea to log what you’re not handling in the else condition. That way if something fishy happens, you'll get a heads-up message logged to the console rather than a silent return from the method.

Finally, there's a little bit of cleanup to do. Still in MasterViewController.m, delete insertNewObject. This method is now incorrect, since insertion is no longer supported.

Build and run your application; you’ll see a nice simple list of items as shown below:

Closed Easy

Swipe one of rows to the left and you'll see a “Delete” button, like so:

Easy delete button

Woo — that was easy. But now it's time to get your hands dirty and dig into the guts of the view hierarchies to see what's going on.

Digging into the View Hierarchy

First things first: you need to see where the delete button lives in the view hierarchy so that you can decide if you can continue to use it in your custom cell.

One of the easiest ways to do this is to color the separate pieces of the view to make it obvious where specific pieces begin and end.

Still working in MasterViewController.m, add the following two lines to tableView:cellForRowAtIndexPath: just above the final return statement:

cell.backgroundColor = [UIColor purpleColor];
cell.contentView.backgroundColor = [UIColor blueColor];

These colors make it clear where these views are in the cell.

Build and run your application again, you’ll see the colored elements as in the screenshot below:

Colored Cells

You can clearly see the contentView in blue stops before the accessory indicator begins, but the cell itself — highlighted in purple — continues all the way over to the edge of the UITableView.

Drag the cell over to the left, and you'll see something similar to the following:

Start to drag cell

It looks like the delete button is actually hiding below the cell. The only way to be 100% sure is to dig a little deeper into the view hierarchy.

To assist your view archaeology, you can use a debugging-only method named recursiveDescription to print out the view hierarchy of any view. Note that this is a private method, and should not be included in any code that’s going to the App Store, but it is highly useful for examining your view hierarchy.

Note: There are a couple of paid apps which allow you to examine the view hierarchy visually: Reveal and Spark Inspector. Additionally, there's an open-source project that does this as well: iOS-Hierarchy-Viewer.

These apps vary in price and quality, but they all require the addition of a library to your project to supports their product. Logging the recursiveDescription is definitely the best way to access this information if you don’t want to install additional libraries within your project.

Add the following log statement to tableView:cellForRowAtIndexPath:, just before the final return statement:

#ifdef DEBUG
  NSLog(@"Cell recursive description:\n\n%@\n\n", [cell performSelector:@selector(recursiveDescription)]);
#endif

Once you add this line, you'll get a warning that the recursiveDescription method hasn’t been declared; it's a private method and the compiler doesn’t know it exists. The wrapper ifdef / endif will make extra sure the line doesn't make it into your release builds.

Build and run your application; you’ll see your console filled to the brim with log statements, similar to the following:

2014-02-01 09:56:15.587 SwipeableCell[46989:70b] Cell recursive description:

<UITableViewCell: 0x8e25350; frame = (0 396; 320 44); text = 'Item #10'; autoresize = W; layer = <CALayer: 0x8e254e0>>
   | <UITableViewCellScrollView: 0x8e636e0; frame = (0 0; 320 44); clipsToBounds = YES; autoresize = W+H; gestureRecognizers = <NSArray: 0x8e1d7d0>; layer = <CALayer: 0x8e1d960>; contentOffset: {0, 0}>
   |    | <UIButton: 0x8e22a70; frame = (302 16; 8 12.5); opaque = NO; userInteractionEnabled = NO; layer = <CALayer: 0x8e22d10>>
   |    |    | <UIImageView: 0x8e20ac0; frame = (0 0; 8 12.5); clipsToBounds = YES; opaque = NO; userInteractionEnabled = NO; layer = <CALayer: 0x8e5efc0>>
   |    | <UITableViewCellContentView: 0x8e23aa0; frame = (0 0; 287 44); opaque = NO; gestureRecognizers = <NSArray: 0x8e29c20>; layer = <CALayer: 0x8e62220>>
   |    |    | <UILabel: 0x8e23d70; frame = (15 0; 270 43); text = 'Item #10'; clipsToBounds = YES; opaque = NO; layer = <CALayer: 0x8e617d0>>

Whoa — that's tons of information. What you’re seeing here is the recursive description log statement, printed out every time a cell is created or recycled. So you should see a few of these, one for each cell that's initially on the screen. recursiveDescription goes through every subview of a particular view and logs out the description of that view aligned just as the view hierarchy is. It does this recursively, so for each subview it goes looks at the subviews of that, and so on.

It's a lot of information, but it is calling description on every view as you step through the view hierarchy. Therefore you'll see the same information as if you logged each individual view on its own, but this output adds a pipe character and some spacing at the front to reflect the structure of the views.

To make it a little easier to read, here's just the class name and frame:

<UITableViewCell; frame = (0 396; 320 44);> //1
   | <UITableViewCellScrollView; frame = (0 0; 320 44); > //2
   |    | <UIButton; frame = (302 16; 8 12.5)> //3
   |    |    | <UIImageView; frame = (0 0; 8 12.5);> //4
   |    | <UITableViewCellContentView; frame = (0 0; 287 44);> //5
   |    |    | <UILabel; frame = (15 0; 270 43);> //6

There are six views within the cell as it exists right now:

  1. UITableViewCell — This is the highest-level view. The frame log shows that it is 320 points wide and 44 points tall - the height and width you’d expect since it’s as wide as the screen and 44 points tall.
  2. UITableViewCellScrollView — While you can’t use this private class directly, its name gives you a pretty good idea as to its purpose in life. It's exactly the same size as the cell itself. We can infer that it's job is to handle the sliding out of the content atop the delete button.
  3. UIButton — This lives at the far right of the cell and serves as the disclosure indicator button. Note that this is not the delete button, but rather the chevron - the disclosure indicator.
  4. UIImageView — This is a subview of the above UIButton and contains the image for the disclosure indicator.
  5. UITableViewCellContentView — Another private class that contains the content of your cell. This view is exposed to the developer as the UITableViewCell’s contentView property. It’s only exposed to the outside world as a UIView, which means you can only call public UIView methods on it; you can’t use any of the private methods associated with this custom subclass.

  6. UILabel — Displays the “Item #” text.

You’ll notice that the delete button appears nowhere in this view hierarchy. Hmm. Maybe it's only added to the hierarchy when the swipe starts. That would make sense as an optimisation. There's no point having the delete button there when it's not necessary. To test this hypothesis, add the following code to tableView:commitEditingStyle:forRowAtIndexPath:, inside the delete editing style if-statement:

#ifdef DEBUG
    NSLog(@"Cell recursive description:\n\n%@\n\n", [[tableView cellForRowAtIndexPath:indexPath] performSelector:@selector(recursiveDescription)]);
#endif

This is the same as before, except this time we need to grab the cell from the table view using cellForRowAtIndexPath:.

Build & run the application, swipe over the first cell, and tap Delete. Then check your console and find the last recursive description for the first cell. You know it's the first cell because the text property of the cell is set to Item #1. You should see something like this:

<UITableViewCell: 0xa816140; frame = (0 0; 320 44); text = 'Item #1'; autoresize = W; gestureRecognizers = <NSArray: 0x8b635d0>; layer = <CALayer: 0xa816310>>
   | <UITableViewCellScrollView: 0xa817070; frame = (0 0; 320 44); clipsToBounds = YES; autoresize = W+H; gestureRecognizers = <NSArray: 0xa8175e0>; layer = <CALayer: 0xa817260>; contentOffset: {82, 0}>
   |    | <UITableViewCellDeleteConfirmationView: 0x8b62d40; frame = (320 0; 82 44); layer = <CALayer: 0x8b62e20>>
   |    |    | <UITableViewCellDeleteConfirmationButton: 0x8b61b60; frame = (0 0; 82 43.5); opaque = NO; autoresize = LM; layer = <CALayer: 0x8b61c90>>
   |    |    |    | <UILabel: 0x8b61e60; frame = (15 11; 52 22); text = 'Delete'; clipsToBounds = YES; userInteractionEnabled = NO; layer = <CALayer: 0x8b61f00>>
   |    | <UITableViewCellContentView: 0xa816500; frame = (0 0; 287 43.5); opaque = NO; gestureRecognizers = <NSArray: 0xa817d40>; layer = <CALayer: 0xa8165b0>>
   |    |    | <UILabel: 0xa8167a0; frame = (15 0; 270 43.5); text = 'Item #1'; clipsToBounds = YES; layer = <CALayer: 0xa816840>>
   |    | <_UITableViewCellSeparatorView: 0x8a2b6e0; frame = (97 43.5; 305 0.5); layer = <CALayer: 0x8a2b790>>
   |    | <UIButton: 0xa8166a0; frame = (297 16; 8 12.5); opaque = NO; userInteractionEnabled = NO; layer = <CALayer: 0xa8092b0>>
   |    |    | <UIImageView: 0xa812d50; frame = (0 0; 8 12.5); clipsToBounds = YES; opaque = NO; userInteractionEnabled = NO; layer = <CALayer: 0xa8119c0>>

Woo! There's the delete button! Now, below the content view, is a view of class UITableViewCellDeleteConfirmationView. So that's where the delete button comes in. Notice that the x-value of its frame is 320. This means that it's positioned at the far end of the scroll view. But the delete button doesn't move as you swipe. So Apple must be moving the delete button every time the scroll view is scrolled. That's not particularly important, but it's interesting!

Back to the cell now.

You’ve also learned more about how the cell works; namely, that UITableViewCellScrollView — which contains the contentView and the disclosure indicator (and the delete button when it's added) — is clearly doing something. You’ve might guess from its name that it’s a subclass of UIScrollView.

You can test this assumption by adding the simple for loop below to tableView:cellForRowAtIndexPath:, just below the line that logs the recursiveDescription:

for (UIView *view in cell.subviews) {
  if ([view isKindOfClass:[UIScrollView class]]) {
    view.backgroundColor = [UIColor greenColor];
  }
}

Build and run your application again; the green highlighting confirms that this private class is indeed a subclass of UIScrollView since it covers up all of the cell’s purple coloring:

Visible Scrollview

Recall that your logs of recursiveDescription showed that the UITableViewCellScrollView’s frame was exactly the same size as that of the cell itself.

But what exactly is this view doing? Keep dragging the cell over to the side and you’ll see that the scroll view powers the “springy” action when you drag the cell and release it, like so:

swipeable-demo

One last thing to be aware of before you start building your own custom UITableViewCell subclass comes straight out of the UITableViewCell Class Reference:

"If you want to go beyond the predefined styles, you can add subviews to the contentView property of the cell. When adding subviews, you are responsible for positioning those views and setting their content yourself."

In plain English, this means that any custom mods to UITableViewCell must be performed in the contentView. You can’t simply add your own views below the cell itself — you have to add them to the cell’s contentView.

This means you're going to have to cook up your own solution to add custom buttons. But never fear, you can quite easily replicate the solution Apple use!

Contributors

Over 300 content creators. Join our team.