制作一个可以滑动操作的 Table View Cell

原文地址:https://github.com/nixzhu/dev-blog/blob/master/2014-04-26-make-swipeable-table-view-cell-actions-without-going-nuts-scroll-views.md

Apple 通过 iOS 7 的邮件(Mail)应用介绍了一种新的用户界面方案——向左滑动以显示一个有着多个操作的菜单。本教程将会向你展示如何制作一个这样的 Table View Cell,而不用因嵌套的 Scroll View 陷入困境。如果你还不知道一个可滑动的 Table View Cell 意味着什么,那么看看 Apple 的邮件应用:

可能你会想,既然 Apple 展示了这种方案,那它应该已将其开放给开发者使用了。毕竟,这能有多难呢?但不幸的是,他们只让开发者使用 Delete 按钮——至少暂时是这样。如果你要添加其他的按钮,或者改变 Delete 按钮上的文字或颜色,那你就必须自己去实现。

译者注:其实文字是可以修改的,但是颜色真的不行!

在本教程中,你将先学习如何实现简单的滑动以删除操作(swipe-to-delete action),之后我们再实现滑动以执行操作(swipe-to-perform-actions)。这会要求你深入研究 iOS 7UITableViewCell的结构,以便复制出我们需要的行为。你将使用到一些我个人非常喜欢的技术用于检查视图层次结构:为视图上色以及使用recursiveDescription方法来打印出视图层次结构。

开始

打开 Xcode,去往File\New\Project…并选择Master-Detail Application,如下所示:

将项目命名为SwipeableCell并填好你自己的 Organization Name 和 Company Identifier 。选择iPhone为目标设备并确保Use Core Data没有被选中,如所示:

对于这样的概念项目的证明,你最好保证数据模型尽量简单。

打开MasterViewController.m并找到viewDidLoad。将默认设置 Navigation Bar Items 的方法替换为如下实现:

- (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];
  }
}

这个方法做了两件事:

  1. 这一行创建并初始化一个NSMutableArray实例,以后你就可以添加对象到它里面了。如果你的数组没有被初始化,那不论你调用addObject:多少次,你的那些对象都不会被存储起来。译者注:读者还是尽量用 Lazy Load 来实现吧!
  2. 这个循环添加了一些字符串到_objects数组,应用运行时,这些字符串将用于显示在 Table View 里。你可以修改 numberOfItems 的值,以存储适合你的更多或更少的字符串。

下一步。找到tableView:cellForRowAtIndexPath:并替换其实现为:

- (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; }

原本tableView:cellForRowAtIndexPath:的样板使用日期字符串作为简单数据;而你的实现使用你的数组里的NSString对象去填充UITableViewCelltextLabel

往下滚动到tableView:canEditRowAtIndexPath:;你会看到这个方法已经设置为返回YES,也就是说, Table View 的每一行都支持编辑。

就在这个方法下边,tableView:commitEditingStyle:forRowAtIndexPath:处理对象的删除。然而,因为你还不能添加任何东西到这个应用里,那就先稍微修改它一下以适应你的需求。

用下面的代码替换tableView:commitEditingStyle:forRowAtIndexPath:

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! 当用户删除某行时,你就用传入的 Index 将那一行的对象从后面的数组中移除,并告知 Table View 它需要移除同一个indexPath所表示的那一行 Cell,一确保模型和视图的匹配。

你的应用只允许“delete”这一种编辑方式,但在 else 分支里用 log 记录你没有在处理什么也不错。如果有某个诡异的事情发生,你将会在控制台得到一个提示消息,这比方法静悄悄地返回要好。

最后,还有一些清理要做。依然在MasterViewController.m里,删除insertNewObject。这个方法现在不正确,因为插入已经不再被支持了。

编译并运行应用;你会看到一个简单列表,如下所示:

滑动某一行到左边,你就会看到一个 “Delete” 按钮,如下所示:

喔~——这很简单。但现在是时候弄脏双手,深挖进视图层次结构,看看里面到底发了什么。

深入视图层次结构(View Hierarchy)

首先:你要找到 Delete 按钮在视图层次结构里的位置,然后你才能决定是否可以将其用于你自定义的 Cell 。

最容易做到这一点的方式是将 View 的各个部分分别染色,以便清楚地看到它们地位置和范围。

继续在MasterViewController.m里工作,添加如下两行到tableView:cellForRowAtIndexPath:里,就在最后的return语句之上:

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

这些颜色足够让我们看清这些视图在 Cell 中的位置。

再次编译并运行,你会看到着色后的元素,如下面的截图所示:

你会清楚地看到蓝色的contentView停止在 Accessory Indicator 之前,但整个 Cell 自身以紫色高亮,填满了到UITableView的边缘。

往左边拖动 Cell ,你会看到类似下面的的界面:

看起来 Delete 按钮实际上隐藏在 Cell 的下面。唯一能 100% 确保的方式是在视图层次结构中再挖深一点。

为了辅助你的视图考古,你可以用一个只能用于调试的方法,叫做recursiveDescription,它能打印出任意视图的视图层次结构。注意这是一个私有方法, `不应该被包含在任何会被放到 App Store 的代码里`,但它对与视图层次结构实在非常有用。

Note:目前有两个付费应用能让你用可视化的方式检查视图层次结构:RevealSpark Inspector。另外,还有一个开源项目也可以很好地做到这件事:iOS-Hierarchy-Viewer
这些应用的价格和质量各有不同,但它们全都要求在你的项目中添加一个库以便支持它们的产品。但如果你不想在项目里安装任何库的话,那recursiveDescription绝对是得到这些信息的最好的方式。

添加如下打印语句到tableView:cellForRowAtIndexPath:中,放在 return 语句之前:

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

一旦添加了这一行代码,你就会得到一个警告,也就是recursiveDescription未被申明;因为它是一个私有方法,编译器并不知道它的存在,ifdef / endif包装器将会额外确保这行代码不会被编译进最终的 release 版里。

编译并运行;你会看到控制台全都是 log 语句,类似下面这样:

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; 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 = (12.5); clipsToBounds = YES; opaque = NO; layer = <CALayer: 0x8e5efc0>> | | <UITableViewCellContentView: 0x8e23aa0; frame = (287 44); opaque = NO; gestureRecognizers = <NSArray: 0x8e29c20>; layer = <CALayer: 0x8e62220>> | | | <UILabel: 0x8e23d70; frame = (15 270 43); text = 'Item #10'; clipsToBounds = NO; layer = <CALayer: 0x8e617d0>>

又要哇~——信息真不少。你所看到的是递归的描述log语句,在每次 Cell 被创建或回收时都会打印。所以你会看到好几个这种消息,因为初始的屏幕上有好几个 Cell 。recursiveDescription会走遍特定视图的每个子视图,输出子视图的描述,并按照视图层次结构排列。它会递归地做这件事,所以对于每个子视图,它也会再去寻找它们的子视图。

虽然信息很多,但它是根据视图层次结构在每个视图上都调用了recursiveDescription。因此如果你单独打印每个子视图的描述,你会看到同样的信息,但这个方法在子视图的输出前加了一个|符号和一些空格,以便反映出视图的结构。

为了更加易读,下面光拿出类名和 Frame 来看:

<UITableViewCell; frame = (44);> //1 | <UITableViewCellScrollView; frame = (44); > //2 | | <UIButton; frame = (12.5)> //3 | | | <UIImageView; frame = (12.5);> //4 | | <UITableViewCellContentView; frame = (//5 | | | <UILabel; frame = (43);> //6

目前 Cell 里有六个视图:

  1. UITableViewCell这是最高层的视图。 Frame 显示它有 320 点宽和 44 点高——宽度和高度都喝预期的一致,因为它和屏幕一样宽,而高度就是 44 点。
  2. UITableViewCellScrollView虽然你不能直接使用这个私有类,但它的名字很好地暗示了它的功能。它的 Size 和 Cell 的一样。据此我们推断它的作用是在 Delete 按钮之上装载滑动出来的内容。
  3. UIButton它在 Cell 的最右边,就是 Disclosure Indicator 按钮。注意这不是 Delete 按钮。
  4. UIImageView是上面UIButton的子视图,装载着 Disclosure Indicator 的图像。
  5. UITableViewCellContentView另外一个私有类,它包含 Cell 的内容。这个类对于开发者来说就是contentView属性。但它只作为一个UIView来暴露在外,这就意味着你只在其上调用使用公开的UIView方法;而不能使用任何与这个类关联的任何私有方法。
  6. UILabel显示 “Item #” 文本。

你会注意到 Delete 按钮并没有显示在上面的视图层次结构排列里。嗯~。可能它只在滑动开始时才被添加到层次结构里。对于优化来说这样做很合理。在不需要 Delete 按钮的时候实在没有必要将其放在那里。要验证这个猜想,就添加如下代码到tableView:commitEditingStyle:forRowAtIndexPath:,就在处理 delete editing style 的 if 语句中:

ifdef DEBUG cellForRowAtIndexPath:indexPath] 这和之前添加的一样,除了这次我们需要滑动 Cell 以便调用tableView:commitEditingStyle:forRowAtIndexPath:

译者注:上面这一段的原文是“This is the same as before,except this time we need to grab the cell from the table view using cellForRowAtIndexPath:.”,按照我的理解,滑动应该调用tableView:commitEditingStyle:forRowAtIndexPath:,这样才能执行我们新添加的语句。

编译并运行;滑动第一个 Cell,并点击 Delete。然后看看控制台的输出,找到最后一个递归描述,即第一个 Cell 的视图层次结构。你知道它是第一个 Cell ,因为它的text属性被设置为Item #1。你应该看到类型下面的打印:

<UITableViewCell: 0xa816140; frame = ('Item #1'; autoresize = W; gestureRecognizers = <NSArray: 0x8b635d0>; layer = <CALayer: 0xa816310>> | <UITableViewCellScrollView: 0xa817070; frame = (NSArray: 0xa8175e0>; layer = <CALayer: 0xa817260>; contentOffset: {82,179)">0}> | | <UITableViewCellDeleteConfirmationView: 0x8b62d40; frame = (82 44); layer = <CALayer: 0x8b62e20>> | | | <UITableViewCellDeleteConfirmationButton: 0x8b61b60; frame = (43.5); opaque = NO; autoresize = LM; layer = <CALayer: 0x8b61c90>> | | | | <UILabel: 0x8b61e60; frame = (11; 52 22); text = 'Delete'; clipsToBounds = YES; userInteractionEnabled = NO; layer = <CALayer: 0x8b61f00>> | | <UITableViewCellContentView: 0xa816500; frame = (NSArray: 0xa817d40>; layer = <CALayer: 0xa8165b0>> | | | <UILabel: 0xa8167a0; frame = (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 NO; layer = <CALayer: 0xa8092b0>> | | | <UIImageView: 0xa812d50; frame = (NO; layer = <CALayer: 0xa8119c0>>

喔~ 看到 Delete 按钮了!在 Content View 下面, 有一个视图的类名为UITableViewCellDeleteConfirmationView。所那里就是 Delete 按钮被放置的位置。注意到它的 Frame 的 x 值是 320。这就意味着它被放置在 Scroll View 的最远端。但这个 Delete 按钮在你滑动时并没有移动。所以 Apple 必须在每次 Scroll View 滚动的同时移动这个 Delete 按钮。虽然这不是特别重要,但它很有趣!

现在回到 Cell。

你同样已经学了不少关于这个 Cell 如何工作的知识;亦即,那个UITableViewCellScrollView,它包含 contentView 和 Disclosure Indicator (以及 Delete 按钮,如果它被添加的话),明显是要做某些事。你可能已经从它的名字以及它是UIScrollView的子类而猜到了。

你可以通过在tableView:cellForRowAtIndexPath:下面添加一个简单的for循环来测试这个假设,就在recursiveDescription那一行下面:

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

再次编译并允许应用;绿色高亮确认了这个私有类确实是UIScrollView的子类,因为它覆盖了 Cell 里所有的紫色。

回想刚才recursiveDescription输出的 log,UITableViewCellScrollView的 Frame 和 Cell 本身的 Size 是一致的。

但是,这个视图到底有什么用?继续拖动 Cell 到左边,你就会看到 Scroll View 在你拖动 Cell 并 释放时提供了 “弹性(springy)”行为,如下所示:

在你创建你自己的自定义UITableViewCell子类之前,还有一件事要注意,它出至UITableViewCell Class Reference

如果你想超越预定义样式,你可以添加子视图到 Cell 的contentView上。在添加子视图时,你自己要负责这些视图的位置以及设置它们的内容。

直白的说,就是,任何对UITableViewCell的自定义操作只能在contentView中进行。你不能将自己的视图加在 Cell 下面——而必须将它们加在 Cell 的contentView上。

这就意味着你将找出你自己的解决方案以便添加自定义按钮。但不要害怕,你可以很容易地复制出 Apple 所使用的方案。

可滑动 Table View Cell 的组成列表

这对你来说是什么意思?到了这里,你就有了一个组成列表来制造出一个UITableViewCell子类,以便放上你自定义的按钮。

我们从 View Stack 的最底部开始列出条目,你的列表如下:

    contentView是你的基础视图,因为你只能将子视图添加到它上面。
  1. 在用户滑动后,任何你想显示的UIButon
  2. 一个位于按钮之上的容器视图来装载你所有的内容。
  3. 你可以使用一个UIScrollView来作为你的容器视图,就像 Apple 使用的,或者使用一个UIPanGestureRecognizer。这同样能够处理滑动去显示/隐藏按钮。你将在项目中采用后一种方案。
  4. 最后,一个装有实际内容的视图。

还有一个可能不那么明显的成分:你必须确保系统提供的UIPanGestureRecognizer—— 它能让你滑动显示 Delete 按钮 —— 不可用。否则系统手势会和自定义手势冲突。

好消息是设置默认滑动手势不可用的操作相当简单。

MasterViewController.m修改tableView:canEditRowAtIndexPath:永远返回NO,如下所示:

BOOL)tableView:(UITableView *)tableView canEditRowAtIndexPath:(return NO; }

编译并运行;试着滑动某个 Cell ,你会发现你不能再滑动去删除了。

为了保持简单,你将使用两个按钮来走完这个教程。但同样的技术也可以再一个按钮上工作,或者超过两个按钮的情况——作为提醒,你可能需要执行一些本文没有涉及到的调整,如果你真的添加了多个按钮,你必须将整个 Cell 滑出才能看到所有的按钮。

创建一个自定义 Cell

你可以从基本视图和手势识别列表可以看到,在 Table View Cell 中有许多要做的事。你将创建一个自定义的UITableViewCell子类,以将所有的逻辑放在同一个地方。

去往File\New\ File…并选择iOS\Cocoa Touch\Objective-C class,将新类命名为SwipeableCell,将它设置为UITableViewCell的子类 ,如下所示:

SwipeableCell.m中设置下列类扩展和IBOutlet,就在#import语句后,@implementation语句前:

@interface SwipeableCell() @property (nonatomic,weak) IBOutlet UIButton *button1; IBOutlet UIButton *button2; IBOutlet UIView *myContentView; IBOutlet UILabel *myTextLabel; @end

下一步,进入 Storyboard 选中UITableViewCell原型,如下所示:

打开 Identity Inspector ,然后修改 Custom Class 为SwipeableCell,如下所示:

现在UITableViewCell原型的名字在左边的 Document Outline 上会显示为 “Swipeable Cell”。右键单击Swipeable Cell – Cell,你会看到一个你之前设置的IBOutlet列表:

首先,你要在 Attributes Inspector 里修改两个地方以便自定义视图。设置 Style 为Custom, Selection 为None, Accessory 也为None,截图如下:

然后,拖两个按钮到 Cell 的 Content View 里。在视图的 Attributes Inspector 区设置每个按钮的背景色为比较鲜艳的颜色,并设置每个按钮的文字颜色为比较易读的颜色,这样你就可以清楚地看到按钮。

将第一个按钮放在右边,和contentView的上下边缘接触。将第二个按钮放在第一个按钮的左边缘处,也和contentView的上下边缘接触。当你做好后,Cell 看起来如下,可能颜色少有差异:

接下来,将每个按钮和对应的 Outlet 关联起来。右键单击到可滑动Cell上打开它的 Outlets,然后将 button1 拖动到到右边的按钮, button2 拖动到左边的按钮,如下:

你需要创建一个方法来处理对每个按钮的点击。

SwipeableCell.m添加如下方法:

IBAction)buttonClicked:(id)sender { if (sender == self.button1) { @"Clicked button 1!"); } else if (sender == self.button2) { @"Clicked button 2!"); } @"Clicked unknown button!"); } }

这个方法处理对两个按钮的点击,通过在控制台打印记录,你就能确定按钮被点击了。

再次打开 Storyboard ,将两个按钮都连接上 Action 。右键单击Swipeable Cell – Cell出现 Outlet 和 Action 的列表。从buttonClicked:Action 拖动到你的按钮,如下:

从事件列表中选择Touch Up Inside,如下所示:

重复上述步骤,用于第二个按钮。现在随便按照任何一个按钮上,都会调用buttonClicked:

SwipeableCell.m添加如下属性:

@property (nonatomic,strong) NSString *itemText;

稍后你将更多的和itemText打交道,但目前,这就是所有你要做的。

MasterViewController.m并在顶部添加如下一行:

import "SwipeableCell.h"

这将保证这个类知道你自定义的 Cell 子类。

替换tableView:cellForRowAtIndexPath:的内容为:

NSIndexPath *)indexPath { SwipeableCell *cell = [tableView forIndexPath:indexPath]; NSString *item = _objects[indexPath.row]; cell.itemText = item; 现在该使用你的新 Cell 而不是标准的UITableViewCell

编译并运行;你会看到如下界面:

添加一个 Delegate

欧耶~ 你的按钮已经出现了!如果你点击任何一个按钮,你都会在控制台看到合适的信息输出。然而,你不能指望 Cell 本身去处理任何直接的 Action 。

比如说,一个 Cell 不能 Present 其他的 View Controller 或直接将其 push 到 Navigation Stack 里。你必须要设置一个 Delegate 来传递按钮的点击事件回到 View Controller 中去处理那个事件。

SwipeableCell.h并在@interface之上添加如下 Delegate 协议:

@protocol SwipeableCellDelegate <NSObject> - (void)buttonOneActionForItemText:(NSString *)itemText; - (buttonTwoActionForItemTextNSString *)itemText; 添加如下 Delegate 属性到SwipeableCell.h,就在itemText属性下面:

id <SwipeableCellDelegate> delegate;

更新SwipeableCell.m中的buttonClicked:为如下所示:

if (sender == self.button1) { [self.delegate buttonOneActionForItemText:self.itemText]; } if (sender == self.button2) { [buttonTwoActionForItemText: 这个更新使得这个方法去调用合适的 Delegate 方法,而不仅仅是打印一句 log。

现在打开MasterViewController.m并添加如下 delegate 方法:

pragma mark - SwipeableCellDelegate - (void)buttonOneActionForItemText:(NSString *)itemText { @"In the delegate,Clicked button one for %@",itemText); } - (void)buttonTwoActionForItemText:( 这个方法目前还是简单的打印到控制台,以确保一切传递都工作正常。

接下来,添加如下协议到MasterViewController.m顶部的类扩展上以符合协议申明:

MasterViewController () <SwipeableCellDelegate> { NSMutableArray *_objects; } 这只是简单地确认这个类会实现SwipeableCellDelegate协议。

最后,你要设置这个 View Controller 为 Cell 的 delegate。

添加如下语句到tableView:cellForRowAtIndexPath:,就在最后的 return 语句之前:

cell.delegate = self;

编译并运行;当你点击按钮时,你就会看到合适的“In the delegate”消息。

为按钮添加 Action

如果你看到log消息很很高兴了,也可以跳过下一节。然而,如果你喜欢更加实在的东西,你可以添加一些处理,这样当 delegate 方法被调用时,你就可以显示已经引入的DetailViewController

添加如下两个方法到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 initWithBarButtonSystemItem:UIBarButtonSystemItemDone target:self action:closeModal)]; [detail.navigationItem setRightBarButtonItem:done]; [presentViewController:navController animated:YES completion:nil]; } //4 - (void)closeModal { [dismissViewControllerAnimated:nil]; }

在上面的代码里,你执行了四个操作:

  1. 从 Storyboard 里取出 Detail View Controller 并设置其 title 和 detailItem 。
  2. 设置一个UINavigationController作为包含 Detail View Controller 的容器,并给你放置 close 按钮的地方。
  3. 添加 close 按钮,关联MasterViewController里的一个 Action。
  4. 设置这个 Action 的响应方法,它将 dismiss 任何以 Modal 方式显示 View Controller

接下来,用下列版本替换你之前添加的两个方法:

NSString *)itemText { [showDetailWithText:[@"Clicked button one for @"Clicked button two for 最后,打开Main.storyboard并选中Detail View Controller。找到 Identity Inspector 并设置Storyboard IDDetailViewController以匹配类名,如下所示:

如果你忘了这一步,instantiateViewControllerWithIdentifier将会因为不合法的参数而 Crash,其异常表示具有这个标识符的 View Controller 并不存在。

编译并运行;点击某个 Cell 中的按钮,然后看着 Modal View Controller 出现,如下面的截图所示:

添加顶层视图并添加滑动 Action

现在你到了视图工作的后段部分,是时候让顶层部分启动并运行起来了。

Main.storyboard并拖一个UIViewSwipeableTableCell上,这个视图将占据整个 Cell 的高和宽,并覆盖按钮,所以在Swipe手势能工作之前,你不会再看到它们了。

如果你要精确地控制,打开 Size Inspector 并设置这个视图地宽和高,分别为 320 和 43:

你同样需要一个约束来将视图钉在 contentView 的边缘。选中视图并点击Pin按钮,选择所有四个间隔约束并设置它们的值为 0 ,如下所示:

连接好这个视图的 Outlet,按照之前介绍的步骤:在左边的导航器里右键单击这个可滑动 Cell 并拖动myContentView到这个新的视图上。

下一步,拖动一个UILabel到视图里;设置其距离左边 20 点,并设置其垂直剧中。再将其连接到myTextLabelOutlet 上。

编译并运行;你的 Cell 看起来有正常了:

添加数据

但为何实际的文本数据没有显示出来?那是因为你只是设置了itemText属性,而没有做会影响myTextLabel的事情。

SwipeableCell.m并添加如下方法:

void)setItemText:(NSString *)itemText { //Update the instance variable _itemText = itemText; //Set the text to the custom label. self.myTextLabel.text = _itemText; }

这个方法覆写了itemText属性的 setter 方法。除了更新后面的实例变量,它还会更新可见的 Label。

最后,为了让接下来的几步的结果更易看到,你将把 item 的 title 变长一点,以便在 Cell 滑动后依然有一些文本可见。

转到MasterViewController.m并更新viewDidLoad中的这一行,这是 item title 生成的地方:

@"Longer Title Item # 编译并运行;你就会看到合适的 item title 显示如下:

手势识别——GO!

终于到了“有趣的”部分——将数学、约束以及手势识别搅和在一起,以方便地处理滑动操作。

首先,在SwipeableCell的类扩展里添加如下这些属性:

CGPoint panStartPoint; @property (nonatomic,179)">CGFloat startingRightLayoutConstraintConstant; @property (nonatomic,93)">IBOutlet NSLayoutConstraint *contentViewRightConstraint; @property (nonatomic,179)">NSLayoutConstraint *contentViewLeftConstraint;

关于你所要做的事情,简短版本是这样的:记录一个 Pan 手势并调整你的View的左右约束,根据 a) 用户将 Cell Pan 了多远 b) Cell 在何处以及合适开始移动。

为了做到这一点,你首先要将这个 IBOutlet 连接到myContentView的左右约束上。这两个约束将视图 钉在 Cell 的contentView中。

通过打开约束列表,你可以找出这两个约束。通过检查每个约束在 Cell 上的高亮你就能找到那合适的两个。在这个例子中,是contentView右边和contentView之间的约束,如下所示:

一旦你定位到合适的约束,就将其连接到合适的 Outlet 上——在本例中,是contentViewRightConstraint,如下图所示:

遵循同样的步骤,连接好contentViewLeftConstraint,它代表contentView左边和contentView之间的约束。

下一步,打开SwipeableCell.m并修改@interface语句的类扩展,添加UIGestureRecognizerDelegate协议:

SwipeableCell() <UIGestureRecognizerDelegate>

然后,依然在SwipeableCell.m里,添加如下方法:

void)awakeFromNib { [awakeFromNib]; self.panRecognizer = [[UIPanGestureRecognizer initWithTarget:panThisCell:)]; self.panRecognizer.delegate = self; [self.myContentView addGestureRecognizer:self.panRecognizer]; }

这里设置了 Pan 手势并将其添加到 Cell 上:

再添加如下方法:

void)panThisCell:(UIPanGestureRecognizer *)recognizer { switch (recognizer.state) { case UIGestureRecognizerStateBegan: self.panStartPoint = [recognizer translationInView:self.myContentView]; @"Pan Began at panStartPoint)); break; case UIGestureRecognizerStateChanged: { CGPoint currentPoint = [recognizer CGFloat deltaX = currentPoint.x - self.panStartPoint.x; @"Pan Moved %f",deltaX); } case UIGestureRecognizerStateEnded: @"Pan Ended"); case UIGestureRecognizerStateCancelled: @"Pan Cancelled"); default: break; } }

这个方法会在 Pan 手势识别器发动时执行,暂时,它只简单地打印 Pan 手势的细节。

编译并运行;用手指拖动 Cell ,你就会看到如下log记录了移动信息:

如果你往初始点的右边滑动,你会看到正数,往初始点的左边滑动就会看到负数。这些数字将用于调整myContentView的约束。

移动这些约束

从本质上将,你需要通过调整将 Cell 的contentView钉住的左、右约束来推动myContentView到左边。右约束将会接受一个正值,而左约束将接受一个绝对值相等的负值。

举例来说,如果myContentView需要往左移动 5 点,那么 右约束将会接受的值是 5,而左约束将接受的值是 -5 。这将会将整个视图往左边滑动 5 点,而不会改变他的宽度。

听起来蛮容易的——但还有许多移动相关的事情要注意。根据 Cell 是否已经打开和用户 Pan 的方向,你要处理不同的一大把事情。

你同样需要知道 Cell 最远可以滑动多远。你将通过计算被按钮覆盖的区域的宽度来确定这一点。最简单的方法是用视图的整个宽度减去最左边的按钮的最小 X 位置。

为了阐明,下面来个 sneak peek ,以明确的图示表明你所要关注的方面:

幸好,感谢CGRectCGGeometry 函数,这些很容易被转换为代码:

添加如下方法到SwipeableCell.m

- (CGFloat)buttonTotalWidth { CGRectGetWidth(self.frame) - CGRectGetMinX(self.button2.frame); }

添加如下两个骨架方法到void)resetConstraintContstantsToZero:(BOOL)animated notifyDelegateDidClose:(BOOL)endEditing { //TODO: Build. } - (void)setConstraintsToShowAllButtons:(BOOL)animated notifyDelegateDidOpen:(BOOL)notifyDelegate { //TODO: Build }

这两个骨架方法——一旦你填上血肉——将 snap 打开 Cell 并 snap 关闭 Cell。在你对 pan 手势识别起添加更多处理后,你会回到这两个方法。

panThisCell:中的UIGestureRecognizerStateBegancase 为下列代码:

case UIGestureRecognizerStateBegan: self.panStartPoint = [recognizer self.myContentView]; self.startingRightLayoutConstraintConstant = self.contentViewRightConstraint.constant; break;

你需要存储 Cell 的初始位置(例如,约束值)以确定 Cell 是要打开还是关闭。

下一步你需要添加更多处理以应对 pan 手势识别器的改变。还是在panThisCell:里,修改UIGestureRecognizerStateChangedcase ,如下所示:

case UIGestureRecognizerStateChanged: { 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,179)">0); //3 if (constant == //4 [resetConstraintContstantsToZero:notifyDelegateDidClose:NO]; } else { //5 self.contentViewRightConstraint.constant = constant; } } else { MIN(-deltaX,[buttonTotalWidth]); //6 if (constant == [buttonTotalWidth]) { //7 [setConstraintsToShowAllButtons:notifyDelegateDidOpen://8 self.contentViewRightConstraint.constant = constant; } } }

上面大部分代码都在 Cell 默认的“关闭”状态下 处理pan手势识别器,下面是细节说明:

  1. 判断 pan 手势是往左还是往右。
  2. 如果右约束常量为 0 ,意味着myContentView完全挡住contentView。因此 Cell 在这里一定已经关闭,而用户准备打开它。
  3. 这是处理用户从做到右滑动以关闭 Cell 的 情况。除了说“你不能做那个”之外,你还要处理的情况是,当用户滑动 Cell 只打开一点点,然后他们希望不必抬起他们的手指来结束此手势就可以滑动它关闭。译者注:就是说,打开一点点不会完全显示出后面的按钮,Cell 会自动关闭。

    因为一个从左到右的滑动会导致deltaX为正值,而从右到左的滑动回到导致deltaX为负值,你必须根据负的deltaX计算出常量以设置到右约束上。因为是从它与0中找出最大值,所以视图不可能往右边走多远。

  4. 如果常量为 0,Cell 就是完全关闭的。调用处理关闭的方法——它(如你回忆起的)在目前还什么也不会做。
  5. 如果常量为不为 0,那么你就将其设置到右手边的约束上。
  6. 否者,如果是从右往做滑动,那么用户试图打开 Cell 。这在个情况里,常量将会小于负deltaX或两个按钮的宽度之和。
  7. 如果目标常量是两个按钮的宽度之和,那么 Cell 就被打开至捕捉点(catch point),你应该调用方法来处理这个打开状态。
  8. 如果常量不是两个按钮的宽度之和,那就将其设置到右约束上。

哟!处理得真不少… 而这个只是处理了 Cell 已经关闭得情况。你现在还要编写代码处理当手势开始时 Cell 就已经部分开启的情况。

就在刚在添加的代码之下添加如下代码:

else { //The cell was at least partially open. CGFloat adjustment = self.startingRightLayoutConstraintConstant - deltaX; //1 MAX(adjustment,150)">//2 //3 [//4 self.contentViewRightConstraint.constant = constant; } } MIN(adjustment,150)">//5 //6 [//7 self.contentViewRightConstraint.constant = constant; } } } self.contentViewLeftConstraint.constant = -self.contentViewRightConstraint.constant; //8 } 这是 if 语句的后半段。因此它用于处理 Cell 原本就打开的情况。

再一次,下面说明你要处理的几个情况:

  1. 在这个情况下,你只是接受deltaX,你就用 rightLayoutConstraint 的原始位置减去deltaX以便得知要做多少调整。
  2. 如果用户从做往右滑动,你必须接受 adjustment 与 0 中的较大值。如果 adjustment 已变成负值,那就说明用户已经把 Cell 滑到边界之外了,Cell 就关闭了,这就让你进入下一个情况。
  3. 如果常量为 0,那么 Cell 已经关闭,你就调用处理其关闭的方法。
  4. 否则,将常量设置到右约束上。
  5. 对于从右到左的滑动,你将接受 adjustment 与 两个按钮宽度之和 中的较小值。如果 adjustment 更大,那就表示用户已经滑出超过捕捉点了。
  6. 如果常量刚好等于两个按钮宽度之和,那么 Cell 就打开了,你必须调用处理 Cell 打开的方法。
  7. 否则,将常量设置到右约束上。
  8. 现在,你已经处理完“Cell关闭”和“Cell部分开启”的情况,在这两个情况里,你都可对左约束做同样的事情:将其设置为右约束常量的负值。这就保证了myContentView的宽度一直保持不变。

编译并运行;现在你可以来回滑动 Cell !它不是非常流畅,而且它在你希望的地方之前的一点就停下了。这是因为你还没有真正实现那两个用于处理打开和关闭 Cell 的方法。

Note:你可以也注意到,Table View 本身已经不会 scroll 了。不要担心,一旦你正确处理好 Cell 的滑动,你就能修复它。

Snap!

接下来,你要让 Cell Snao 进入合适的位置。你会注意到,如果你放手 Cell 会停到合适的位置。

在你进入方法开始处理之前,你需要一个单独的生成动画的方法。

void)updateConstraintsIfNeeded:(BOOL)animated completion:(void (^)(BOOL finished))completion { float duration = 0; if (animated) { duration = 0.1; } [UIView animateWithDuration:duration delay:options:UIViewAnimationOptionCurveEaseOut animations:^{ [layoutIfNeeded]; } completion:completion]; }

Note:0.1 秒的间隔和 ease-out curve 动画都是我从实践和错误中总结出来的。如果你找到其他更让你看着愉悦的速度或动画类型,可以自由修改它们。

接下来,你将填充那两个处理打开和关闭的骨架方法。记得在 Apple 的原始实现里,因为使用了UIScrollView子类作为最底层的试图,所以会有一点弹性。

要让事情看起来正确,你将在 Cell 撞到边界时给它一点弹性。你同样要确保contentViewmyContentView有同样的backgroundColor以造成弹性非常顺滑的错觉。

添加如下常量到SwipeableCell.m顶部,就在 import 语句之下:

static CGFloat const kBounceValue = 20.0f;

这个常量存储了弹性值,将用于你的弹性动画中。

如下更新setConstraintsToShowAllButtons:notifyDelegateDidOpen:

BOOL)notifyDelegate { //TODO: Notify delegate. //1 if (self.startingRightLayoutConstraintConstant == [buttonTotalWidth] && self.contentViewRightConstraint.constant == [buttonTotalWidth]) { return; } //2 self.contentViewLeftConstraint.constant = -[buttonTotalWidth] - kBounceValue; self.contentViewRightConstraint.constant = [buttonTotalWidth] + kBounceValue; [updateConstraintsIfNeeded:animated completion:^(BOOL finished) { //3 self.contentViewLeftConstraint.constant = -[buttonTotalWidth]; self.contentViewRightConstraint.constant = [buttonTotalWidth]; [BOOL finished) { //4 self.startingRightLayoutConstraintConstant = self.contentViewRightConstraint.constant; }]; }]; }

这个方法在 Cell 完全打开时执行。下面解释发生了什么:

  1. 如果 Cell 已经开启,约束已经到达完全开启值,那就返回——否则弹性操作将会一次又一次的发生,就像你继续滑动超过总按钮宽度那样。
  2. 你初始设置约束值为按钮总宽度和弹性值的结合值,它将 Cell 拉到左边一点点,这样才好 snap 回来。然后你就调用动画来实现这个设置。
  3. 当第一个动画完成,发动第二个动画,它将 Cell 正好打开在从按钮宽度的位置。
  4. 当第二个动画完成,重设起始约束否则你会看到多次弹跳。

resetConstraintContstantsToZero:notifyDelegateDidClose::

//TODO: Notify delegate. 0 && self.contentViewRightConstraint.constant == 0) { //Already all the way closed,no bounce necessary return; } self.contentViewRightConstraint.constant = -kBounceValue; self.contentViewLeftConstraint.constant = BOOL finished) { self.contentViewRightConstraint.constant = 0; self.contentViewLeftConstraint.constant = 0; [BOOL finished) { self.startingRightLayoutConstraintConstant = self.contentViewRightConstraint.constant; }]; }]; }

如你所见,这类似于setConstraintsToShowAllButtons:notifyDelegateDidOpen:,但它的逻辑是关闭 Cell 而不是打开。

编译并运行;随意滑动 Cell 到它的捕捉点,你就会在放手时看到弹性行为。

然而,如果你在 Cell 完全开启或完全关闭之前将释放手指,它将会卡在中间。Whoops! 你还没有处理触摸结束或被取消的情况。

找到panThisCell:用下列代码替换UIGestureRecognizerStateEndedcase :

case UIGestureRecognizerStateEnded: if (self.startingRightLayoutConstraintConstant == //1 //Cell was opening CGFloat halfOfButtonOne = CGRectGetWidth(self.button1.frame) / 2; //2 if (self.contentViewRightConstraint.constant >= halfOfButtonOne) { //3 //Open all the way [YES]; } else { //Re-close [YES]; } } //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 [//Close [YES]; } } 在这里,你根据 Cell 是否已经打开或关闭以及手势结束时 Cell 的位置在执行不同的处理。具体来讲:

  1. 通过检查开始右约束值,得知手势开始时 Cell 是否已经打开或关闭。
  2. 如果 Cell 是关闭的,那你就正在打开它,你要让 Cell 自动滑动到打开,至少需要先滑动右边按钮(self.button1)一半的宽度。因为你在测量约束的常量,你只需要计算实际的按钮宽度,而不是它在视图中的 X 位置。
  3. 接下来,测试约束是否已被打开至超过你希望让 Cell 自动打开的点。如果已经超过,那就自动打开 Cell。如果没有,那就自动关闭 Cell。
  4. 此处表示 Cell 从打开的状态开始,你需要那个能让 Cell 自动 snap 关闭的点,至少需要超过最左边按钮的一半。 将不是最左边的按钮的那些按钮的宽度加起来,在这个情况里,只有 self.button1 而已,再加上最左边按钮的一半——也就是 self.button2 —— 以便找到需要的检查点。
  5. 测试约束是否以及超过这个点,即你希望 Cell 自动关闭的那个点。如果超过了,关闭 Cell。如果没有,那就重新打开 Cell。

最后,你还要处理一下手势被取消的情况。用如下代码替换UIGestureRecognizerStateCancelledcase :

case UIGestureRecognizerStateCancelled: //Cell was closed - reset everything to 0 [YES]; } //Cell was open - reset to the open state [YES]; } 这个处理相当直白;由于用户取消了触摸,表示他们不想改变 Cell 当前的状态,所以你只需要将一切都设置为它们原本的样子即可。

编译并运行;滑动 Cell ,你会看到 Cell Snap 到打开或关闭,而不论你的手指再哪里,如下所示:

更好地处理 Table View

在最终完成前,只有少数几步了!

首先,你的UIPanGestureRecognizer有时候会影响UITableView的 Scroll 操作。由于你已经设置了 Cell 的 Pan 手势识别器 的UIGestureRecognizerDelegate,你只需要实现一个(有些滑稽且冗长命名的) delegate 方法即可将一切恢复正常。

pragma mark - UIGestureRecognizerDelegate - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer { YES; }

这个方法告知各手势识别器,它们可以同时工作。

编译并运行;打开第一个 Cell 然后你依然可以 Scroll tableView 。

还有一个 Cell 重用引起的小问题:各个行不记得它们的状态,看起来是因为 Cell 重用了它们的视图的 开启/关闭 状态,然后它们的视图就不能正确反应用户的操作了。要查看这一情况,打开一个 Cell ,然后将 Table Scroll 一点点。你就会注意每次都有一个 Cell 始终保持打开状态,但每次都不同。

要修复这个问题头一半,添加如下方法到void)prepareForReuse { [prepareForReuse]; [NO NO]; }

这个方法确保 Cell 在其回收重利用时再次关闭。

要解决这个问题的后一半,你将添加一个公共方法给 Cell 以促使其打开。然后你会添加一些 delegate 方法以允许MasterViewController去管理那个 Cell 是打开的。

SwipeableCell.h。在SwipeableCellDelegate协议的申明里,添加如下两个新的方法,就在已存在的那两个下面:

void)cellDidOpen:(UITableViewCell *)cell; - (void)cellDidClose:(UITableViewCell *)cell;

这些方法将会通知 delegate —— 在你的情况里,就是 Master View Controller —— 某个 Cell 被打开或关闭了。

添加如下公共方法申明到SwipeableCell@interface里:

void)openCell;

接下来,打开SwipeableCell.m并添加openCell的实现:

void)openCell { [ 这个方法允许 delegate 修改 Cell 的状态。

依然在用一个文件里,找到resetConstraintsToZero:notifyDelegateDidOpen:并替换其中TODO为如下代码:

if (notifyDelegate) { [cellDidClose:self]; }

接下来,找到setConstraintsToShowAllButtons:notifyDelegateDidClose:并替换其中cellDidOpen: 这两个修改会在一个 swipe 手势完成时通知 delegate ,无论 Cell 是否以及打开或关闭。

添加如下属性申明到MasterViewController.m顶部的类扩展里:

NSMutableSet *cellsCurrentlyEditing;

它将存储当前已被打开的 Cell 的列表。

添加如下代码到viewDidLoad的最后:

self.cellsCurrentlyEditing = [NSMutableSet new];

这个初始化保证了之后你可以正常使用数组。

现在在同一个文件里添加如下方法实现:

void)cellDidOpen:(UITableViewCell *)cell { NSIndexPath *currentEditingIndexPath = [self.tableView indexPathForCell:cell]; [self.cellsCurrentlyEditing addObject:currentEditingIndexPath]; } - (void)cellDidClose:(UITableViewCell *)cell { [removeObject:[indexPathForCell:cell]]; }

注意到你添加的时 Index Path 而不是 Cell 本身到列表里。如果你直接添加 Cell 对象,那么之后你就会看到同样的问题,在 Cell 被回收后再次被打开。用了这个方法,你就可以使用合适 的 Index Path 来打开 Cell 了。

最后,添加下面几行到tableView:cellForRowAtIndexPath:,就在 return 语句之前:

if ([containsObject:indexPath]) { [cell openCell]; }

如果当前的 Cell 的 Index Path 在列表里,它就会将其设置为打开。

编译并运行;全都搞定了!你现在有了一个能够 Scroll 的 Table View,还能处理 Cell 的打开和关闭状态,并在 Cell 的任意被点击时,使用 delegate 方法来加载任何任务。

相关文章

软件简介:蓝湖辅助工具,减少移动端开发中控件属性的复制和粘...
现实生活中,我们听到的声音都是时间连续的,我们称为这种信...
前言最近在B站上看到一个漂亮的仙女姐姐跳舞视频,循环看了亿...
【Android App】实战项目之仿抖音的短视频分享App(附源码和...
前言这一篇博客应该是我花时间最多的一次了,从2022年1月底至...
因为我既对接过session、cookie,也对接过JWT,今年因为工作...