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:html
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.ios
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.git
Ready to see what buttons and actions are underneath those innocent-looking table view cells? Let’s get started!github
Open Xcode, go to File\New\Project… and select a Master-Detail Application for iOS as shown below:spring
Name your project SwipeableCell and fill in your own organization name and company identifier. SelectiPhone as the target device and make sure the Use Core Data checkbox is unchecked, as shown below:api
For a proof of concept project like this, you want to keep the data model as simple as possible.app
Open MasterViewController.m and find viewDidLoad
. Replace the default method which sets up the navigation bar items with the following implementation:less
- (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:ide
addObject:
as many times as you want, but your objects won’t be stored anywhere._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 returnYES
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 theelse
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:
Swipe one of rows to the left and you’ll see a 「Delete」 button, like so:
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.
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 totableView: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:
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:
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: Revealand 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:
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.
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.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.
UIImageView
— This is a subview of the above UIButton
and contains the image for the disclosure indicator.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.
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 totableView: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 usingcellForRowAtIndexPath:
.
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 classUITableViewCellDeleteConfirmationView
. 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:
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:
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 thecontentView
. You can’t simply add your own views below the cell itself — you have to add them to the cell’scontentView
.
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!
So what does this mean for you? Well, at this point you have a list of obvious ingredients for cooking up aUITableViewCell
subclass with your own custom buttons.
Going in reverse z-order with the items at the 「bottom」 of the view stack first, you have the following:
contentView
as your base view, since it’s required that you add subviews to this view.UIButtons
you want to display after the user swipes.UIScrollView
to hold your container view, like Apple use, or you could use aUIPanGestureRecognizer
. This can also handle the swipes to reveal/hide the buttons. You’ll take the latter approach in your project.There’s one ingredient that may not be as obvious: you have to ensure the existingUIPanGestureRecognizer
— which lets you swipe to show the delete button — is disabled. Otherwise that gesture recognizer will collide with the custom one you’re adding to your project.
The good news is that disabling the default swipe is pretty simple.
Open MasterViewController.m. Modify tableView:canEditRowAtIndexPath:
to always return NO
as follows:
- (BOOL)tableView:(UITableView *)tableView canEditRowAtIndexPath:(NSIndexPath *)indexPath { return NO; } |
Build and run your application; swipe one of the items and you’ll find that you can no longer swipe to delete.
To keep it simple, you’ll walk through this example with two buttons, but these same techniques will work with one button, or more than two buttons — though be warned you may need to add a few tweaks not covered in this article if you add so many buttons that you’d have to slide the entire cell out of view to see them all.
You can see from the basic list of views and gesture recognizers that there’s an awful lot going on in the table view cell. You’ll want to create your own custom UITableViewCell
subclass to keep all the logic in one place.
Go to File\New\ File… and select iOS\Cocoa Touch\Objective-C class. Name the new classSwipeableCell and make it a subclass of UITableViewCell, like so:
Set up the following class extension and IBOutlets
in SwipeableCell.m, just below the #import
statement and above the @implementation
statement:
@interface SwipeableCell() @property (nonatomic, weak) IBOutlet UIButton *button1; @property (nonatomic, weak) IBOutlet UIButton *button2; @property (nonatomic, weak) IBOutlet UIView *myContentView; @property (nonatomic, weak) IBOutlet UILabel *myTextLabel; @end |
Next, go into your storyboard and select the UITableViewCell prototype, as shown below:
Open the Identity Inspector, then change the Custom Class to SwipeableCell, like so:
The name of the UITableViewCell prototype now appears as 「Swipeable Cell」 in the Document Outline on the left. Right-click on the item that says Swipeable Cell – Cell, you’ll see the list of IBOutlets you set up above:
First, you’ll need to change a couple things in the Attributes Inspector to customize the view. Set the Style to Custom, the Selection to None, and the Accessory to None, as shown in the screenshot below:
Next, drag two Buttons into the cell’s content view. Set each button’s background color in the View section of the Attributes Inspector to some distinctive color and set each button’s text color to something legible so you can see the buttons clearly.
Pin the first button to the right side, top, and bottom of the contentView. Pin the second button to the left edge of the first button, and to the top and bottom of the contentView. When you’re done, the cell should look something like this, although your colors may differ:
Next, hook up each of your buttons to the appropriate outlets. Right-click the swipeable cell to open up its outlets, then drag from the button1 outlet to the right button, and button2 to the left button, as such:
You need to create a method to handle taps on each of these buttons.
Open SwipeableCell.m and add the following method:
- (IBAction)buttonClicked:(id)sender { if (sender == self.button1) { NSLog(@"Clicked button 1!"); } else if (sender == self.button2) { NSLog(@"Clicked button 2!"); } else { NSLog(@"Clicked unknown button!"); } } |
This handles button taps from either of the buttons and logs it to the console so you can confirm which button was tapped.
Open the Storyboard again, and hook up the action for both buttons to this new method. Right-click theSwipeable Cell – Cell to bring up its list of outlets and actions. Drag from the buttonClicked: action to your button, like so:
Select Touch Up Inside from the list of events, as shown below:
Repeat the above steps for the second button. Now tapping on either button calls buttonClicked:
.
Since you’re customizing the cell’s content view, you can’t rely on the built-in text label. Instead, you’ll need to add your own property and method to set the cell’s text.
Open SwipeableCell.h and add the following property:
@property (nonatomic, strong) NSString *itemText; |
You’ll be doing more with the itemText
property later, but for now, this is all you need.
Open MasterViewController.m and add the following line to the top:
#import "SwipeableCell.h" |
This ensures the class knows about your custom cell subclass.
Replace the contents of tableView:cellForRowAtIndexPath:
with the following:
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { SwipeableCell *cell = [tableView dequeueReusableCellWithIdentifier:@"Cell" forIndexPath:indexPath]; NSString *item = _objects[indexPath.row]; cell.itemText = item; return cell; } |
It’s now your new cell class being used instead of the standard UITableViewCell
.
Build and run your application; you’ll see something like the following:
Hooray — your buttons are there! If you tap on each button, you’ll see the appropriate log messages in your console. However, you don’t want to have the cell itself take any direct action.
For instance, a cell can’t present another view controller or push directly onto the navigation stack. You’ll have to set up a delegate to pass the button tap event back to the view controller to handle that event.
Open SwipeableCell.h and add the following delegate protocol declaration above the @interface
statement:
@protocol SwipeableCellDelegate <NSObject> - (void)buttonOneActionForItemText:(NSString *)itemText; - (void)buttonTwoActionForItemText:(NSString *)itemText; @end |
Add the following delegate property to SwipeableCell.h, just below your property for itemText
:
@property (nonatomic, weak) id <SwipeableCellDelegate> delegate; |
Update buttonClicked:
in SwipeableCell.m as shown below:
- (IBAction)buttonClicked:(id)sender { if (sender == self.button1) { [self.delegate buttonOneActionForItemText:self.itemText]; } else if (sender == self.button2) { [self.delegate buttonTwoActionForItemText:self.itemText]; } else { NSLog(@"Clicked unknown button!"); } } |
This updates the method to call the appropriate delegate methods instead of simply creating an entry in the log.
Now, open MasterViewController.m and add the following delegate methods to the implementation:
#pragma mark - SwipeableCellDelegate - (void)buttonOneActionForItemText:(NSString *)itemText { NSLog(@"In the delegate, Clicked button one for %@", itemText); } - (void)buttonTwoActionForItemText:(NSString *)itemText { NSLog(@"In the delegate, Clicked button two for %@", itemText); } |
These methods will simply log to the console to ensure everything is passing through properly.
Next, add the following protocol conformance declaration to the class extension at the top ofMasterViewController.m:
@interface MasterViewController () <SwipeableCellDelegate> { NSMutableArray *_objects; } @end |
This simply indicates that this class conforms to the SwipeableCellDelegate
protocol.
Finally, you need to set this view controller as the cell’s delegate.
Add the following line to tableView:cellForRowAtIndexPath:
just before the final return statement:
cell.delegate = self; |
Build and run your application; you’ll see the appropriate 「in the delegate」 messages firing off when you tap on the buttons.
If you’re happy with the log messages, feel free to skip to the next section. However, if you’d like something a little more tangible, you can add some handling to show the included DetailViewController when one of the delegate methods is called.
Add the following two methods to MasterViewController.m:
- (void)showDetailWithText:(NSString *)detailText { //1 UIStoryboard *storyboard = [UIStoryboard storyboardWithName:@"Main" bundle:nil]; DetailViewController *detail = [storyboard instantiateViewControllerWithIdentifier:@"DetailViewController"]; detail.title = @"In the delegate!"; detail.detailItem = detailText; //2 UINavigationController *navController = [[UINavigationController alloc] initWithRootViewController:detail]; //3 UIBarButtonItem *done = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemDone target:self action:@selector(closeModal)]; [detail.navigationItem setRightBarButtonItem:done]; [self presentViewController:navController animated:YES completion:nil]; } //4 - (void)closeModal { [self dismissViewControllerAnimated:YES completion:nil]; } |
You perform four actions in the code above:
UINavigationController
to contain the detail view controller and to give you a place to add the close button.MasterViewController
.Next, replace the methods you added earlier with the following implementations:
- (void)buttonOneActionForItemText:(NSString *)itemText { [self showDetailWithText:[NSString stringWithFormat:@"Clicked button one for %@", itemText]]; } - (void)buttonTwoActionForItemText:(NSString *)itemText { [self showDetailWithText:[NSString stringWithFormat:@"Clicked button two for %@", itemText]]; } |
Finally, open Main.storyboard and click on the Detail View Controller. Select the Identity Inspector and set the Storyboard ID to DetailViewController to match the class name, like so:
If you forget this step, instantiateViewControllerWithIdentifier
will crash on an invalid argument exception stating that a view controller with that identifier doesn’t exist.
Build and run the application; click one of the buttons in a cell, and watch your modal view controller appear, as shown in the following screenshot:
Now that you have the bottom part of the view working, it’s time to get the top portion up and running.
Open Main.storyboard and drag a UIView
into your SwipeableTableCell. The view should take up the entire height and width of the cell and cover your buttons so you won’t able to see them until you get the swipe working.
If you want to be precise, you can open the Size Inspector and set the view’s width and height to 320 and 43, respectively:
You’ll also need a constraint to pin the view to the edges of the content view. Select the view and click thePin button. Select all four spacing constraints and set their values to 0 as shown below:
Hook this new view up to its outlet by following the same steps as before: right-click the swipe able cell in the navigator on the left and drag from the myContentView outlet to the new view.
Next, drag a UILabel into the view; pin it 20 points from the left side of the view and center it vertically. Hook this label up to the myTextLabel outlet.
Build and run your application; your cells are looking somewhat normal again:
But why is the actual cell text data not showing up? That’s because you’re only assigning the itemText
to a property rather than doing anything that affects myTextLabel
.
Open SwipeableCell.m and add the following method:
- (void)setItemText:(NSString *)itemText { //Update the instance variable _itemText = itemText; //Set the text to the custom label. self.myTextLabel.text = _itemText; } |
This is an override of the default setter for the itemText
property.
Aside from updating the backing instance variable, the above method also updates the visible label.
Finally, to make the result of the next few steps a little easier to see, you’re going to make the title of the item a little longer so that some text will still be visible when the cell is swiped.
Head back to MasterViewController.m and update the following line in viewDidLoad
where the item titles are generated:
Build and run your application; you can now see the appropriate item titles as shown below:
Now here comes the 「fun」 part — building up the math, the constraints, and the gesture recognizers that facilitate the swiping action.
First, add the following properties to your SwipeableCell
class extension at the top of SwipeableCell.m:
@property (nonatomic, strong) UIPanGestureRecognizer *panRecognizer; @property (nonatomic, assign) CGPoint panStartPoint; @property (nonatomic, assign) CGFloat startingRightLayoutConstraintConstant; @property (nonatomic, weak) IBOutlet NSLayoutConstraint *contentViewRightConstraint; @property (nonatomic, weak) IBOutlet NSLayoutConstraint *contentViewLeftConstraint; |
The short version of what you’re going to be doing is to track a pan gesture and then adjust the left and right constraints on your view based on a) how far the user has panned the cell and b) where the cell was when it started.
In order to do that, you’ll first need to hook up the IBOutlets for the left and right constraints of themyContentView view. These constraints pin that view to the cell’s contentView.
You can figure out which constraints these are by flipping open the list of constraints and examining which ones light up as you go through the list until you find the appropriate ones. In this case, it’s the constraint between the right side of myContentView and the main contentView as shown below:
Once you’ve located the appropriate constraint, hook up the appropriate outlet — in this case, it’s thecontentViewRightConstraint, as such:
Follow the same steps to hook up the contentViewLeftConstraint to the constraint between the left side of myContentView and the main contentView.
Next, open SwipeableCell.m and modify the @interface
statement for the class extension category so that it conforms to the UIGestureRecognizerDelegate
protocol as follows:
@interface SwipeableCell() <UIGestureRecognizerDelegate> |
Then, still in SwipeableCell.m, add the following method:
- (void)awakeFromNib { [super awakeFromNib]; self.panRecognizer = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(panThisCell:)]; self.panRecognizer.delegate = self; [self.myContentView addGestureRecognizer:self.panRecognizer]; } |
This sets up the pan gesture recognizer and adds it to the cell.
Also add the following method:
- (void)panThisCell:(UIPanGestureRecognizer *)recognizer { switch (recognizer.state) { case UIGestureRecognizerStateBegan: self.panStartPoint = [recognizer translationInView:self.myContentView]; NSLog(@"Pan Began at %@", NSStringFromCGPoint(self.panStartPoint)); break; case UIGestureRecognizerStateChanged: { CGPoint currentPoint = [recognizer translationInView:self.myContentView]; CGFloat deltaX = currentPoint.x - self.panStartPoint.x; NSLog(@"Pan Moved %f", deltaX); } break; case UIGestureRecognizerStateEnded: NSLog(@"Pan Ended"); break; case UIGestureRecognizerStateCancelled: NSLog(@"Pan Cancelled"); break; default: break; } } |
This is the method that’s called when the pan gesture recogniser fires. For now, it simply logs the pan gesture details to the console.
Build and run your application; drag your finger across the cell and you’ll see all the logs firing with the movement, like so:
You’ll see positive numbers if you swipe to the right of your initial touch point, and negative numbers if you swipe to the left of your initial touch point. These numbers will be used to adjust the constraints ofmyContentView
.
Essentially, you need to push myContentView
over to the left by adjusting the left and right constraints that pin it to the cell’s contentView
. The right constraint will take a positive value, and the left constraint will take an equal but negative value.
For instance, if myContentView
needs to be moved 5 points to the left, then the right constraint will take a value of 5 and the left constraint will take a value of -5. This slides the entire view over to the left by 5 points without changing its width.
Sounds easy — but there’s a lot of moving parts to watch out for. You have to handle a whole lot of things very differently depending on whether the cell is already open or not, and what direction the user is panning.
You also need to know how far the cell is allowed to slide open. To do this, you’ll have to calculate the width of the area covered by the buttons. The easiest way is to subtract the minimum X position of the leftmost button from the full width of the view.
To clarify, here’s a sneak peek ahead to more clearly illustrate the dimensions you’ll need to be concerned with:
Luckily, thanks to the CGRect CGGeometry functions, this is super-easy to translate into code.
Add the following method to SwipeableCell.m:
- (CGFloat)buttonTotalWidth { return CGRectGetWidth(self.frame) - CGRectGetMinX(self.button2.frame); } |
Add the following two skeleton methods to SwipeableCell.m:
- (void)resetConstraintContstantsToZero:(BOOL)animated notifyDelegateDidClose:(BOOL)endEditing { //TODO: Build. } - (void)setConstraintsToShowAllButtons:(BOOL)animated notifyDelegateDidOpen:(BOOL)notifyDelegate { //TODO: Build } |
These two skeleton methods — once you flesh them out — will snap the cell open and snap the cell closed. You’ll come back to these in a bit once you’ve added more handling in the pan gesture recognizer.
Replace the UIGestureRecognizerStateBegan
case of panThisCell:
with the following code:
case UIGestureRecognizerStateBegan: self.panStartPoint = [recognizer translationInView:self.myContentView]; self.startingRightLayoutConstraintConstant = self.contentViewRightConstraint.constant; break; |
You need to store the initial position of the cell (i.e. the constraint value), to determine whether the cell is opening or closing.
Next you need to start adding more handling for when the pan gesture recognizer has changed. Still in,panThisCell:
, change the UIGestureRecognizerStateChanged
case to look like this:
case UIGestureRecognizerStateChanged: { CGPoint currentPoint = [recognizer translationInView:self.myContentView]; CGFloat deltaX = currentPoint.x - self.panStartPoint.x; BOOL panningLeft = NO; if (currentPoint.x < self.panStartPoint.x) { //1 panningLeft = YES; } if (self.startingRightLayoutConstraintConstant == 0) { //2 //The cell was closed and is now opening if (!panningLeft) { CGFloat constant = MAX(-deltaX, 0); //3 if (constant == 0) { //4 [self resetConstraintContstantsToZero:YES notifyDelegateDidClose:NO]; } else { //5 self.contentViewRightConstraint.constant = constant; } } else { CGFloat constant = MIN(-deltaX, [self buttonTotalWidth]); //6 if (constant == [self buttonTotalWidth]) { //7 [self setConstraintsToShowAllButtons:YES notifyDelegateDidOpen:NO]; } else { //8 self.contentViewRightConstraint.constant = constant; } } } |
Most of the code above deals with pan gestures starting from cells in their default 「closed」 state. Here’s what’s going on in detail:
myContentView
is flush up against the contentView
. Therefore the cell must be closed at this point and the user is attempting to open it.deltaX
and the right-to-left swipe will result in a negative value, you must calculate the constant to set on the right constraint based on the negative ofdeltaX
. The maximum of this and zero is taken, so that the view can’t go too far off to the right.deltaX
or the total width of both buttons.Phew! That’s a lot of handling…and that’s just for the case where the cell was already closed. You now need the code to handle the case when the cell is partially open when the gesture starts.
Add the following code directly below the code you just added:
else { //The cell was at least partially open. CGFloat adjustment = self.startingRightLayoutConstraintConstant - deltaX; //1 if (!panningLeft) { CGFloat constant = MAX(adjustment, 0); //2 if (constant == 0) { //3 [self resetConstraintContstantsToZero:YES notifyDelegateDidClose:NO]; } else { //4 self.contentViewRightConstraint.constant = constant; } } else { CGFloat constant = MIN(adjustment, [self buttonTotalWidth]); //5 if (constant == [self buttonTotalWidth]) { //6 [self setConstraintsToShowAllButtons:YES notifyDelegateDidOpen:NO]; } else { //7 self.contentViewRightConstraint.constant = constant; } } } self.contentViewLeftConstraint.constant = -self.contentViewRightConstraint.constant; //8 } break; |
This is the other side of the outer if-statement. It is therefore the case where the cell is initially open.
Once again, here’s an explanation of the various cases you’re handling:
deltaX
– you’re subtracting deltaX
from the original position of the rightLayoutConstraint to see how much of an adjustment has been made.myContentView
stays consistent no matter what you’ve had to do to the right constraint.Build and run your application; you can now pan the cell back and forth! It’s not super-smooth, and it stops a little bit before you’d like it to. This is because you haven’t yet implemented the two methods that handle opening and closing the cell.
Note: You may also notice that the table view itself doesn’t scroll at the moment. Don’t worry. Once you’ve got the cells sliding open properly, you’ll fix that.
Next up, you need to make the cell snap into place as appropriate. You’ll notice at the moment that the cell just stops if you let go.
Before you get into the methods that handle this, you’ll need a single method to create an animation.
Open SwipeableCell.m and add the following method:
- (void)updateConstraintsIfNeeded:(BOOL)animated completion:(void (^)(BOOL finished))completion { float duration = 0; if (animated) { duration = 0.1; } [UIView animateWithDuration:duration delay:0 options:UIViewAnimationOptionCurveEaseOut animations:^{ [self layoutIfNeeded]; } completion:completion]; } |
Next, you’ll need to flesh out the two skeleton methods that open and close the cell. Remember that in the original implementation, there’s a bit of a bounce since it uses a UIScrollView
subclass as one of the lowest z-index superviews.
To make things look right, you’ll need to give your cell a bit of a bounce when it hits either edge. You’ll also have to ensure your contentView
and myContentView
have the same backgroundColor
for the optical illusion of the bounce to look as seamless as possible.
Add the following constant to the top of SwipeableCell.m, just underneath the import statement:
static CGFloat const kBounceValue = 20.0f; |
This constant stores the bounce value to be used in all your bounce animations.
Update setConstraintsToShowAllButtons:notifyDelegateDidOpen:
as follows:
- (void)setConstraintsToShowAllButtons:(BOOL)animated notifyDelegateDidOpen:(BOOL)notifyDelegate { //TODO: Notify delegate. //1 if (self.startingRightLayoutConstraintConstant == [self buttonTotalWidth] && self.contentViewRightConstraint.constant == [self buttonTotalWidth]) { return; } //2 self.contentViewLeftConstraint.constant = -[self buttonTotalWidth] - kBounceValue; self.contentViewRightConstraint.constant = [self buttonTotalWidth] + kBounceValue; [self updateConstraintsIfNeeded:animated completion:^(BOOL finished) { //3 self.contentViewLeftConstraint.constant = -[self buttonTotalWidth]; self.contentViewRightConstraint.constant = [self buttonTotalWidth]; [self updateConstraintsIfNeeded:animated completion:^(BOOL finished) { //4 self.startingRightLayoutConstraintConstant = self.contentViewRightConstraint.constant; }]; }]; } |
This method executes when the cell should open up all the way. Here’s what’s going on:
Update resetConstraintContstantsToZero:notifyDelegateDidClose:
as follows:
- (void)resetConstraintContstantsToZero:(BOOL)animated notifyDelegateDidClose:(BOOL)notifyDelegate { //TODO: Notify delegate. if (self.startingRightLayoutConstraintConstant == 0 && self.contentViewRightConstraint.constant == 0) { //Already all the way closed, no bounce necessary return; } self.contentViewRightConstraint.constant = -kBounceValue; self.contentViewLeftConstraint.constant = kBounceValue; [self updateConstraintsIfNeeded:animated completion:^(BOOL finished) { self.contentViewRightConstraint.constant = 0; self.contentViewLeftConstraint.constant = 0; [self updateConstraintsIfNeeded:animated completion:^(BOOL finished) { self.startingRightLayoutConstraintConstant = self.contentViewRightConstraint.constant; }]; }]; } |
As you can see, this is similar to setConstraintsToShowAllButtons:notifyDelegateDidOpen:
, but the logic closes the cell instead of opening it.
Build and run your application; drag the cell all the way to its catch points. You’ll see the bouncing action when you release the cell.
However, if you release the cell before either it’s fully open or fully closed, it’ll remain stuck in the middle. Whoops! You’re not handling the two cases of touches ending or being cancelled.
Find panThisCell:
and replace the handling for the UIGestureRecognizerStateEnded
case with the following:
case UIGestureRecognizerStateEnded: if (self.startingRightLayoutConstraintConstant == 0) { //1 //Cell was opening CGFloat halfOfButtonOne = CGRectGetWidth(self.button1.frame) / 2; //2 if (self.contentViewRightConstraint.constant >= halfOfButtonOne) { //3 //Open all the way [self setConstraintsToShowAllButtons:YES notifyDelegateDidOpen:YES]; } else { //Re-close [self resetConstraintContstantsToZero:YES notifyDelegateDidClose:YES]; } } else { //Cell was closing CGFloat buttonOnePlusHalfOfButton2 = CGRectGetWidth(self.button1.frame) + (CGRectGetWidth(self.button2.frame) / 2); //4 if (self.contentViewRightConstraint.constant >= buttonOnePlusHalfOfButton2) { //5 //Re-open all the way [self setConstraintsToShowAllButtons:YES notifyDelegateDidOpen:YES]; } else { //Close [self resetConstraintContstantsToZero:YES notifyDelegateDidClose:YES]; } } break; |
Here, you’re performing handling based on whether the cell was already open or closed as well as where the cell was when the pan gesture ended. In detail:
Finally, you’ll need a bit of handling in case the touch event is cancelled. Replace theUIGestureRecognizerStateCancelled
case with the following:
case UIGestureRecognizerStateCancelled: if (self.startingRightLayoutConstraintConstant == 0) { //Cell was closed - reset everything to 0 [self resetConstraintContstantsToZero:YES notifyDelegateDidClose:YES]; } else { //Cell was open - reset to the open state [self setConstraintsToShowAllButtons:YES notifyDelegateDidOpen:YES]; } break; |
This handling is a bit more straightforward; since the user has cancelled the touch, they don’t want to change the existing state of the cell, so you just need to set everything back the way it was.
Build and run your application; swipe the cell and you’ll see that the cell snaps open and closed no matter where you lift your finger, as shown below:
There’s just a few more steps before you’re done!
First, your UIPanGestureRecognizer
can sometimes interfere with the one which handles the scroll action on the UITableView
. Since you’ve already set up the cell to be the pan gesture recognizer’sUIGestureRecognizerDelegate
, you only have to implement one (comically verbosely named) delegate method to make this work.
Add the following method to SwipeableCell.m:
#pragma mark - UIGestureRecognizerDelegate - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer { return YES; } |
This method tells the gesture recognizers that they can both work at the same time.
Build and run your application; open the first cell and you can now scroll the tableview.
There’s still an issue with cell reuse: rows don’t remember their state, so as cells are reused their opened/closed state in the view won’t reflect the actions of the user. To see this, open a cell, then scroll the table a bit. You’ll notice that one cell always remains open, but it’s a different one each time.
To fix the first half of this issue, add the following method to SwipeableCell.m:
- (void)prepareForReuse { [super prepareForReuse]; [self resetConstraintContstantsToZero:NO notifyDelegateDidClose:NO]; } |
This method ensures the cell re-closes before it’s recycled.
To solve the second half of the issue, you’re going to add a public method to the cell to facilitate its opening. Then you’ll add some delegate methods to allow MasterViewController
to manage which cells are open.
Open SwipeableCell.h. In the SwipeableCellDelegate
protocol declaration, add the following two new methods below the existing methods:
- (void)cellDidOpen:(UITableViewCell *)cell; - (void)cellDidClose:(UITableViewCell *)cell; |
These methods will notify the delegate — in your case, the master view controller — that a cell has opened or closed.
Add the following public method declaration in the @interface
declaration for SwipeableCell:
- (void)openCell; |
Next, open SwipeableCell.m and add the following implementation for openCell
:
- (void)openCell { [self setConstraintsToShowAllButtons:NO notifyDelegateDidOpen:NO]; } |
This method allows the delegate to change the state of a cell.
Still working in the same file, find resetConstraintsToZero:notifyDelegateDidOpen:
and replace the TODO
at the top of the method with the following code:
if (notifyDelegate) { [self.delegate cellDidClose:self]; } |
Next, find setConstraintsToShowAllButtons:notifyDelegateDidClose:
and replace the TODO
at the top of that method with the following code:
if (notifyDelegate) { [self.delegate cellDidOpen:self]; } |
These two changes notify the delegate when a swipe gesture has completed and the cell has either opened or closed the menu.
Add the following property declaration to the top of MasterViewController.m, inside the class extension category:
@property (nonatomic, strong) NSMutableSet *cellsCurrentlyEditing; |
This stores a list of cells that are currently open.
Add the following code to the end of viewDidLoad
:
self.cellsCurrentlyEditing = [NSMutableSet new]; |
This initializes the set so you can add things to it later.
Now add the following methods to the same file:
- (void)cellDidOpen:(UITableViewCell *)cell { NSIndexPath *currentEditingIndexPath = [self.tableView indexPathForCell:cell]; [self.cellsCurrentlyEditing addObject:currentEditingIndexPath]; } - (void)cellDidClose:(UITableViewCell *)cell { [self.cellsCurrentlyEditing removeObject:[self.tableView indexPathForCell:cell]]; } |
Note that you’re adding the index paths rather than the cells themselves to the list of cells currently editing. If you added the cell objects directly, then you’d see the same issue where the cells would appear open as they are recycled. With this method, you’ll be able to open the cells at the appropriate index paths instead.
Finally, add the following lines to tableView:cellForRowAtIndexPath:
just before the final return statement:
if ([self.cellsCurrentlyEditing containsObject:indexPath]) { [cell openCell]; } |
If the current cell’s index path is in the set, it should be set to open.
Build & run the application. That’s it! You now have a table view that scrolls, maintains the open and closed state of cells, and uses delegate methods to launch arbitrary tasks from button taps in any cell.
The final project is available here as a download. I’ll be working with what I’ve developed here to assemble an open source project to make things a bit more flexible – I’ll be posting a link in the forums when it’s ready to roll.
Any time you’re you’re trying to replicate something Apple did without knowing exactly how they did it, you’ll find that there are many, many ways to do it. This is just one solution to this problem; however, it’s one of the only solutions I’ve found that doesn’t involve lots of crazy mucking around with nested scroll views and the resulting gesture recognizer collisions that can get extremely hairy to untangle! :]
A couple of resources that were very helpful in writing this article, but which ultimately took very different approaches, were Ash Furrow’s article that got the entire ball rolling, and Massimiliano Bigatti’s BMXSwipeableCell project which showed just how deep the rabbit hole can go with the UIScrollView
approach.
If you have any suggestions, questions, or related pieces of code, fire away in the comments!