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

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

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

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

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. SelectiPhone 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 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:

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

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

    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.

    1. 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.
    2. 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.

  1. UIImageView — This is a subview of the above UIButton and contains the image for the disclosure indicator.
  2. 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.

  3. 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:

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

A List Of Ingredients for a Swipeable Table View Cell

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:

  1. The contentView as your base view, since it’s required that you add subviews to this view.
  2. Any UIButtons you want to display after the user swipes.
  3. A container view above the buttons to hold all of your content.
  4. Either a 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.
  5. Finally, the views with your actual content.

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.

Creating the Custom Cell

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:

Creating custom cell

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:

Select Table View Cell

Open the Identity Inspector, then change the Custom Class to SwipeableCell, like so:

Change Custom Class

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:

New Name and Outlets

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:

Reset Cell Items

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:

Buttons Added to Prototype Cell

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:

swipeable-button1

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:

swipeable-buttonClicked

Select Touch Up Inside from the list of events, as shown below:

swipeable-touchupinside

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:

ALL THE BUTTONS!

Adding a delegate

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 @interfacestatement:

@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.

Adding actions to 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:

  1. Grab the detail view controller out of the storyboard and set its title and detail item for display.
  2. Set up a UINavigationController to contain the detail view controller and to give you a place to add the close button.
  3. Add the close button with a target within the MasterViewController.
  4. Set up the actual target for the close button, which dismisses any modal view controller.

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:

Add Storyboard Identifier

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:

View Launched from Delegate

Adding the Top Views And The Swipe Action

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:

swipeable-320-43

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:

swipeable-constraint

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:

Back to cells

Adding the data

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:

NSString *item = [NSString stringWithFormat:@"Longer Title Item #%d", i];

Build and run your application; you can now see the appropriate item titles as shown below:

Longer Item Titles displayed in custom label

Gesture recognisers – go!

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:

Highlighting Constraints

Once you’ve located the appropriate constraint, hook up the appropriate outlet — in this case, it’s thecontentViewRightConstraint, as such:

Hook Up Constraint to IBOutlet

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:

Pan Logs

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.

Moving those constraints

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:

Minimum x of button 2

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:

  1. Here you determine whether you’re presently panning to the left or the right of your original pan point.
  2. If the right layout constraint’s constant is equal to zero, that means myContentView is flush up against the contentView. Therefore the cell must be closed at this point and the user is attempting to open it.
  3. This is the case where the user swipes from left to right to close the cell. Rather than just saying “you can’t do that”, you have to handle the case where the user swipes the cell open a bit then wants to swipe it closed without having lifted their finger to end the gesture.
     
    Since a left-to-right swipe results in a positive value for 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.
  4. If the constant is zero, the cell is being closed completely. Fire the method that handles closing — which, as you’ll recall, does nothing at the moment.
  5. If the constant is not zero, then you should set it to the right-hand side constraint.
  6. Otherwise, if you’re panning right to left, the user is attempting to open the cell. In this case, the constant will be the lesser of either the negative value of deltaX or the total width of both buttons.
  7. If the target constant is the total width of both buttons, the cell is being opened to the catch point and you should fire the method that handles opening.
  8. If the constant is not the total width of both buttons, then set the constant to the right constraint’s constant.

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:

  1. In this case, you’re not just taking the deltaX – you’re subtracting deltaX from the original position of the rightLayoutConstraint to see how much of an adjustment has been made.
  2. If the user is panning left to right, you must take the greater of the adjustment or 0. If the adjustment has veered into negative numbers, that means the user has swiped beyond the edge of the cell, and the cell is closed, which leads you to the next case.
  3. If you’re seeing the constant equal to 0, the cell is closed and you must fire the method that handles closing the cell.
  4. Otherwise, you set the constant to the right constraint.
  5. In the case of panning right to left, you’ll want to take the lesser of the adjustment and the total button width. If the adjustment is higher, then the user has swiped too far past the catch point.
  6. If you’re seeing the constant equal to the total button width, the cell is open, and you must fire the method that handles opening the cell.
  7. Otherwise, set the constant to the right constraint.
  8. Now, you’re finally out of both the “cell was closed” and “cell was at least partially open” conditions, and you can do the same thing to the left constraint’s constant in any of these cases: set it to the negative value of the right constraint’s constant. This ensures the width of 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.

Snap!

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];
}
Note: The duration of 0.1 seconds and the animation curve as an ease-out curve are values that I found looked about right through trial and error. If you find other speeds or animation curves more pleasing to your eye, feel free to change them!

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:

  1. If the cell started open and the constraint is already at the full open value, just bail — otherwise the bouncing action will happen over and over and over again as you continue to swipe past the total button width.
  2. You initially set the constraints to be the combined value of the total button width and the bounce value, which pulls the cell a bit further to the left than it should go so that it can snap back. Then you fire off the animation for this setting.

  3. When the first animation completes, fire off a second animation which brings the cell to rest in an open position at exactly the button width.
  4. When the second animation completes, reset the starting constraint or you’ll see multiple bounces.

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:

  1. Check whether the cell was already open or closed when the pan started by checking the starting right layout constraint.
  2. If the cell was closed and you are opening it, you want the point at which the cell automatically slides all the way open to be half of the width of the rightmost button — self.button1. Since you’re measuring against the constraint’s constant, you only need to calculate the actual width of the button itself, not its X position in the view. 

  3. Next, test if the constraint has been opened past the point where you’d like the cell to open automatically. If it’s past that point, automatically open the cell. If it’s not, automatically close the cell.
  4. In the case where the cell starts as open, you want the point at which the cell will automatically snap closed to be a point more than halfway past the leftmost button. Add together the widths of any buttons which are not the leftmost button — in this case, just self.button1 — and half the width of the leftmost button — self.button2 — to find the point to check. 

  5. Test if the constraint has moved past the point where you’d like the cell to close automatically. If it has, close the cell. If it hasn’t, re-open the cell.

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:

swipeable-bounce

Playing Nicer With The Table View

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 TODOat 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.

Where To Go From Here

yeti holding videos
Want to learn even faster? Save time with ourvideo courses

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 UIScrollViewapproach.

If you have any suggestions, questions, or related pieces of code, fire away in the comments!

转载于:https://www.cnblogs.com/ioriwellings/p/6558684.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值