我相信购买这本书的朋友,一定使用过简书这个APP了。那么肯定见过简书上面漂亮的tableView滑动菜单了。系统的tableView是只需要配置几个代理方法, 就可以实现cell的左右侧滑菜单的. 一般会被用来作为编辑,删除等使用. 但是虽然在使用上挺方便的. 不过系统提供的的样式局限性很大, 就像QQ的侧滑样式, 只能显示字符并且动画效果很单一. 不过, 我们实际开发中会遇到的可能并不仅仅是这么简单, 可能是上面图片显示的这样本节中就分享给朋友们吧, 也许不久的开发中你就会遇到类似的需求了, 那就再好不过了.
本节中, 我们将实现自定义的tableViewCell的侧滑菜单, 并且实现四种常见的动画效果, 同时简书炫酷的侧滑效果也一并实现了.
这个看上去比较小的需求, 笔者最初尝试实现的时候仍然是不知道如何下手去完成, 经过一段时间的考虑后才有一些想法. 后来大概使用了两种方式来实现. 因为在实现这个需求之前笔者自己实现过抽屉菜单的需求(我们上一节中也已经实现了), 最初想到的就是在每一个cell类似抽屉菜单一样, 增加两个左右的抽屉菜单, 然后打开和关闭就和我们处理抽屉菜单一样, 最终是顺利的实现了这个需求. 用上去还是比较方便. 后来再次回头研究的时候, 想到了另外一种比较方便的实现方法. 下面我们就使用这种方法来实现了.
1. 首先我们新建一个ZJSwipeTableViewCell : UITableViewCell
来实现滑动菜单的需求, 然后方便使用者直接使用或者继承我们这个就可以了. 我们首先很清楚的是cell上面需要添加一个滑动手势UIPanGestureRecognizer
,来处理滑动.增加这个属性panGesture
,然后重写cell的初始化方法, 添加上这个手势到cell上面, 注意我们同时希望支持xib自定义的cell, 所以重写的初始化方法中要包括- (instancetype)initWithCoder:(NSCoder *)aDecoder
.
- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier {
if (self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]) {
[self commonInit];
}
return self;
}
- (instancetype)init {
if (self = [super init]) {
[self commonInit];
}
return self;
}
- (instancetype)initWithFrame:(CGRect)frame {
if (self = [super initWithFrame:frame]) {
[self commonInit];
}
return self;
}
- (instancetype)initWithCoder:(NSCoder *)aDecoder {
if (self = [super initWithCoder:aDecoder]) {
[self commonInit];
}
return self;
}
- (void)commonInit {
_panGesture = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(handlePanGesture:)];
self.panGesture.delegate = self;
[self addGestureRecognizer:self.panGesture];
}
2. 因为我们希望实现的滑动菜单中的按钮
可以展示多种样式的内容, 比如只展示图片, 只展示文字, 可以同时展示图片和文字, 不过图片在上方文字在下方. 所以我们首先自定义一下我们需要的按钮. 新建一个ZJSwipeButton : UIButton
,然后我们自定义一个初始化的方法便于后面使用, 需要的参数有图片,文字,点击响应的block
, 然后我们在这个方法里面根据文字的长度和图片的尺寸设置好按钮的宽高.
- (instancetype)initWithTitle:(NSString *)title image:(UIImage *)image onClickHandler:(ZJSwipeButtonOnClickHandler)onClickHandler {
if (self = [super init]) {
_onClickHandler = [onClickHandler copy];
[self addTarget:self action:@selector(swipeBtnOnClick:) forControlEvents:UIControlEventTouchUpInside];
[self setTitle:title forState:UIControlStateNormal];
[self setImage:image forState:UIControlStateNormal];
[self setTitleColor:[UIColor blueColor] forState:UIControlStateNormal];
self.backgroundColor = [UIColor greenColor];
CGFloat margin = 10;
// 计算文字尺寸
CGSize textSize = [title boundingRectWithSize:CGSizeMake(MAXFLOAT, 200.f) options:NSStringDrawingUsesLineFragmentOrigin attributes:@{NSFontAttributeName: self.titleLabel.font, NSForegroundColorAttributeName: self.titleLabel.textColor } context:nil].size;
// 计算按钮宽度, 取图片宽度和文字宽度较大者
CGFloat btnWidth = MAX(image.size.width+margin, textSize.width+margin);
// 文字居中
self.titleLabel.textAlignment = NSTextAlignmentCenter;
// 暂时的, 宽高有效, 其他的会在父控件(ZJSwipeView)中调整
self.frame = CGRectMake(0.f, 0.f, btnWidth, image.size.height+textSize.height+margin);
}
return self;
}
3. 然后ZJSwipeButton
还有一点需要处理的是, 如果需要显示图片的时候,在layoutSubviews
中重新设置imageView和titleLabel的frame, 让图片在上面,文字在下面显示, 同时需要处理按钮点击的响应事件, 执行外部传递的block就可以了.
- (void)layoutSubviews {
[super layoutSubviews];
if (self.imageView.image) {
// 设置了图片, 重新调整imageView和titleLabel的frame
// 让图片在上, 文字在下显示
CGFloat selfHeight = self.bounds.size.height;
CGFloat selfWidth = self.bounds.size.width;
CGSize imageSize = self.imageView.image.size;
CGFloat imageAndTextMargin = 5.f;
CGFloat margin = (selfHeight - imageSize.height - self.titleLabel.bounds.size.height - imageAndTextMargin)/2;
self.imageView.frame = CGRectMake((selfWidth-imageSize.width)/2, margin, imageSize.width, imageSize.height);
// 计算文本frame
CGRect titleLabelFrame = self.titleLabel.frame;
titleLabelFrame.origin.x = 0;
titleLabelFrame.origin.y = CGRectGetMaxY(self.imageView.frame) + imageAndTextMargin;
titleLabelFrame.size.width = selfWidth;
self.titleLabel.frame = titleLabelFrame;
}
}
// 按钮点击响应事件
- (void)swipeBtnOnClick:(UIButton *)btn {
if (_onClickHandler) {
_onClickHandler(btn);
}
}
4. 处理好了我们的侧滑菜单上的按钮, 接下来需要处理我们的侧滑菜单了, 侧滑菜单分为左右菜单, 上面用来容纳左右的按钮, 所以我们希望将这些按钮的frame设置等工作全部交给侧滑菜单来处理, 而不需要我们在ZJSwipeTableViewCell里面来完成. 所以新建一个ZJSwipeView : UIView
, 自定义初始化方法. 我们需要的参数有, 菜单上需要显示的按钮和高度.
- (instancetype)initWithSwipeButtons:(NSArray<ZJSwipeButton *> *)swipeButtons height:(CGFloat)height {
if (self = [super init]) {
CGFloat btnX = 0.f;
CGFloat allBtnWidth = 0.f;
// 为每个按钮设置frame, 同时计算好所有的按钮的宽度之和, 作为swipeView的宽度
// 注意这里是反向遍历添加的
for (ZJSwipeButton *button in [swipeButtons reverseObjectEnumerator]) {
[self addSubview:button];
button.frame = CGRectMake(btnX, 0, button.bounds.size.width, height);
btnX += button.bounds.size.width;
allBtnWidth += button.bounds.size.width;
}
// 设置frame 宽高有效, x, y在swipeTableViewCell中还会相应的调整
self.frame = CGRectMake(0.f, 0.f, allBtnWidth, height);
self.backgroundColor = [UIColor whiteColor];
}
return self;
}
5. 完成了ZJSwipeView和ZJSwipeButton
的处理, 接下来就是正式处理ZJSwipeTableViewCell
了. 因为上面提到的第一种方法, 在处理滑动的时候cell上的内容的滚动不是很方便, 所以笔者换了一种实现方式, 那就是我们经常使用到的截图
. 我们在开始滑动的时候将cell截图, 然后将这张截图添加到cell上面, 随着手势滚动的时候只需要调整截图的位置就可以了, 这样就不用考虑cell内部的位置调整了. 让我们的工作量就减小了很多很多.在结合我们之前完成抽屉菜单的经验, 我们可以将左右的swipeView添加在同一个overlayerContentView
来管理, 然后手势移动的时候只需要改变overlayerContentView
的和cell的截图snapView
的frame就可以了. 所以自然我们会添加上这些属性.
// cell的截图
@property (strong, nonatomic) UIView *snapView;
// 所有添加的subviews的容器, 滑动时覆盖在cell上
@property (nonatomic, strong) UIView *overlayerContentView;
// 右边的滑动菜单
@property (nonatomic, strong) ZJSwipeView *rightView;
// 左边的滑动菜单
@property (nonatomic, strong) ZJSwipeView *leftView;
6. 我们之前完成了抽屉菜单ZJDrawerController
, 那么我们很清楚, 类似的我们还需要一些属性来帮助我们处理在手势滑动的过程中的滑动方向的判断和滑动的距离的获取.
// 滑动操作的类型
typedef NS_ENUM(NSUInteger, ZJSwipeOperation) {
ZJSwipeOperationNone,
ZJSwipeOperationOpenLeft,
ZJSwipeOperationCloseLeft,
ZJSwipeOperationOpenRight,
ZJSwipeOperationCloseRight
};
// 记录手势开始的时候`overlayerContentView`的x
CGFloat _beginContentViewX;
// 记录手势开始的时候`snapView`的x
CGFloat _beginSnapViewX;
// 记录手势开始的时候手指的位置, 便于处理手指松开的时候判断滑动了多远,是否完成滑动
CGFloat _beginX;
7. 我们就可以处理滑动手势了, 在手势处理的方法中, 我们需要处理的是: 手势开始的时候设置好左右侧滑菜单和cell截图并且记录需要的初始数据, 在手指滑动状态的时候, 我们需要根据滑动操作的类型, 相应的改变滑动菜单的frame和切换动画, 最后是在手指离开的时候, 我们根据滑动的距离和离开时的滑动速度来判断是否打开和关闭菜单. 手势开始的状态.
case UIGestureRecognizerStateBegan: {
// 设置左右侧滑菜单和截图
[self setupSwipeViewWithSwipeVelocityX:velocityX];
// 记录初始数据
_beginX = locationX;
_beginSnapViewX = self.snapView.zj_x;
_beginContentViewX = self.overlayerContentView.zj_x;
self.swipeOperation = ZJSwipeOperationNone;
}
8. 设置左右侧滑菜单和截图, 我们知道, 如果左右的swipeView没有创建, 我们首先需要创建他们, 这个时候我们就需要获取到swipeView上面需要显示的按钮swipeButton
, 这些按钮的创建应该是外部的使用者来创建的, 所以我们可以使用代理来完成, 新定义一个协议ZJSwipeTableViewCellDelegate
添加两个代理方法来获取我们这个cell所需要的左右侧滑按钮.
@protocol ZJSwipeTableViewCellDelegate <NSObject>
@required
/**
* 左滑cell时显示的button 返回nil表示不创建左边菜单
*
* @param indexPath cell的位置
*/
- (NSArray<ZJSwipeButton *> *)tableView:(UITableView *)tableView leftSwipeButtonsAtIndexPath:(NSIndexPath *)indexPath;
/**
* 右滑cell时显示的button 返回nil表示不创建右边菜单
*
* @param indexPath cell的位置
*/
- (NSArray<ZJSwipeButton *> *)tableView:(UITableView *)tableView rightSwipeButtonsAtIndexPath:(NSIndexPath *)indexPath;
@end
9. 可以看到我们上面定义的代理方法里面需要的参数有tableView和indexPath, 那么我们swipeTableViewCell
怎么获取到它所在的tableView和所在tableView上的indexPath了? 这又是我们很常用的一个处理, 遍历cell的superView即可获取到, 因为我们其他地方会用到cell所在的tableView, 所以我们把tableView写成一个属性, 不过要注意的是, 应该使用weak
. 获取cell在tableView上的indexPath就使用tableView的一个方法就可以直接获取到了
- (UITableView *)tableView {
if (!_tableView) {
UIView *nextView = self.superview;
while (self.superview) {
// 遍历cell的superView, 当superView是UITableView的时候, 说明找到了
// cell所在的tableView
if ([nextView isKindOfClass:[UITableView class]]) {
_tableView = (UITableView *)nextView;
break;
}
nextView = nextView.superview;
}
}
return _tableView;
}
// 获取当前cell的indexPath
NSIndexPath *indexPath = [self.tableView indexPathForCell:self];
10. 然后就可以设置左右侧滑菜单和截图, 我们将leftView和rightView
添加到overlayerContentView
上面并且设置frame和我们在完成ZJDrawerController
的时候完全一样, 所以这里就不再赘述设置frame的思路了. 如果不清楚的朋友, 可以去阅读书籍对应的章节, 不得不说的是, 你应该要很清楚设置这些frame的思路, 否则我们在手指改变的处理方法中改变snapView和overlayerContentView
的frame你可能就很难明白其中的原因了. 这里需要注意的是, 我们应该按需创建, 创建之前一定要判断是否需要创建和添加, 这一部分的代码比较简单和繁琐, 请读者直接阅读源码;
if (self.overlayerContentView == nil) {
NSArray<ZJSwipeButton *> *leftBtns = [self.delegate tableView:self.tableView leftSwipeButtonsAtIndexPath:[self.tableView indexPathForCell:self]];
NSArray<ZJSwipeButton *> *rightBtns = [self.delegate tableView:self.tableView rightSwipeButtonsAtIndexPath:[self.tableView indexPathForCell:self]];
// 不符合条件不创建
// 左边按钮个数为0 说明不需要创建左边菜单,这个时候向右滑动试图打开左边菜单 直接就返回了
// 右边按钮个数为0 说明不需要创建右边菜单,这个时候向左滑动试图打开右边菜单 直接就返回了
if ((leftBtns.count==0 && velocityX>0) || (rightBtns.count==0 && velocityX<0)) {
return;
}
if (self.leftView == nil) {
//创建leftView并且设置frame和添加到overlayerContentView
}
if (self.righttView == nil) {
//创建rightView并且设置frame和添加到overlayerContentView
}
// 先添加overlayerContentView 到cell上, 再添加cell截图, 注意顺序
[self addSubview:self.overlayerContentView];
// 再添加截图
if (self.snapView == nil) {
// 系统提供的方法 iOS7之后就不用我们自己来绘图实现截图的需求了
self.snapView = [self snapshotViewAfterScreenUpdates:NO];
self.snapView.frame = self.bounds;
// 添加到cell上
[self addSubview:self.snapView];
}
}
11. 接下来是处理手指滑动过程中snapView和overlayerContentView
的frame的改变了. 这一部分和我们当时实现ZJDrawerController
的时候非缩放效果的处理几乎完全一样. 如果读者在之前理解的比较好或者自己动手实现过, 那么阅读这一段代码使不会有任何问题的, 这里就简单提及几个地方了. snapView因为是跟随手指同步滚动的, 所以他的frame.x的改变和手指的位置改变完全同步, 并不受到滚动方向的影响. 而overlayerContentView
则需要根据是打开左边, 关闭左边, 打开右边, 关闭右边
这四种不同的操作在对应的设置frame.x. 这里以打开左边菜单为例. 代码较多, 请君仔细阅读.
case UIGestureRecognizerStateChanged: {
// 始终同步滚动 snapView
CGFloat tempSnapViewX = _beginSnapViewX;
tempSnapViewX += transitionX;
self.snapView.zj_x = tempSnapViewX;
// 向右滑动说明是 打开左边 或者关闭右边
if (transitionX>0) {
// 右边菜单存在, 并且开始滑动时截图的x = 右边菜单宽度的负值
// 说明这次手势开始的时候右边的菜单是打开的, 正在关闭右边的菜单
if (self.rightView && _beginSnapViewX == -self.rightView.zj_width) {
// 记录为正在关闭右边菜单, 便于在手指离开的时候判断
self.swipeOperation = ZJSwipeOperationCloseRight;
// 影藏左边菜单 显示右边菜单
[self hideAndShowSwipeViewNeededWithShowleft:NO];
// 手指向右移动的距离 >= 右边菜单的宽度, 说明右边菜单已经完全关闭
// 手指再继续右移就变成了打开左边菜单的操作了, 这个时候就要
// 将各个变量设置为打开左边菜单的初始值
if (transitionX>=self.rightView.zj_width) {
// 右边关闭完成 --- 变为打开左边
// 手势设置移动为0
[panGesture setTranslation:CGPointZero inView:self];
// 重置开始X
_beginContentViewX = -self.leftView.zj_width*self.animatedTypePercent;
_beginX = locationX;
_beginSnapViewX = 0;
self.overlayerContentView.zj_x = -self.leftView.zj_width*self.animatedTypePercent;
}
else {
// 正在关闭右边 改变overlayerContentView的x
CGFloat tempX = _beginContentViewX;
tempX += transitionX*self.animatedTypePercent;
self.overlayerContentView.zj_x = tempX;
}
// 这是我们模仿简书的打开和关闭的时候的动画效果进行的frame计算, 需要一点数学能力
[self animateSwipeButtonsWithPercent:transitionX/self.rightView.zj_width];
}
}
}
12. 最后是手指离开屏幕的时候, 我们应该根据滚动的距离和手指离开时的速度来判断这一次操作是否完成还是返回操作前的状态. 这里就以关闭右边菜单为例. 其他情况类似的呢.
case UIGestureRecognizerStateEnded: {
CGFloat velocityX = [panGesture velocityInView:self].x;
if (self.swipeOperation == ZJSwipeOperationCloseRight) {
// 如果手指移动的距离 > 我们定义的百分比 说明应该执行动画关闭右边菜单
if (fabs(_beginX - locationX) > self.rightView.zj_width*self.threholdPercent) {
[self animatedCloseRight];
}
else {
// 如果手指移动的距离较小, 就判断手指离开的速度是否大于我们定义的最小速度
// 如果大于证明应该执行动画关闭右边菜单, 否则说明关闭右边失败, 重新打开 右边菜单
if (fabs(velocityX) > _threholdSpeed)
[self animatedCloseRight];
else
[self animatedOpenRight];
}
}
}
13. 关于我们定义的ZJSwipeViewAnimatedStyle
这个枚举中, 定义了四种动画类型, 其中的三种和我们实现ZJDrawerController
的三种动画方式完全相同, 第四种模仿简书的动画的代码需要一点点的数学能力去理解, 这里即不在提及了, 请读者直接参考源码, 实现相应的四种动画效果.
14. 完成了上面的工作, 我们就可以写测试代码了, 在ViewController中添加tableView然后使用我们的ZJSwipeTableViewCell, 实现对应的返回左右菜单按钮的代理方法, 顺利的话, 就能正常的运行了, 然后可以左右侧滑并且上面的按钮显示正常点击也是正常的还有我们实现的四种动画效果. 看上去不错. 不过问题就来了, 现在不能滚动tableView了, 因为我们添加在cell上的手势和系统的手势发生了冲突, 于是我们, 需要在我们添加的panGesture的代理方法中判断如果是准备上下滑动就不要开始手势, 就不会和系统的手势冲突了.
- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer {
if (gestureRecognizer == self.panGesture) {
CGPoint transion = [self.panGesture translationInView:self];
return transion.y == 0; // 是否是上下滑动
}
}
15. 现在在运行项目tableView就能正常的滚动了, 但是现在我们发现在开始滚动和点击其他地方的时候滑动菜单并不会自动关闭. 笔者这里的处理方式是在ZJSwipeTableViewCell所在的tableView上面添加一个tap手势, 当侧滑菜单打开的时候, 点击tableView就关闭滑动菜单,但是, 我们要注意处理tap手势和tableView点击cell的手势的冲突, 所以我们在tap手势的代理中判断, 只有在滑动菜单打开的时候才能执行tap手势.
if (gestureRecognizer == self.tapGesture) { // 所有的cell公用这一个tapGesture
if (self.overlayerContentView) {
return YES;
}
else {
return NO;
}
}
16. 处理tableView开始滚动的时候关闭打开的滑动菜单, 笔者是通过kvo来监听tableView手势状态的改变, 在手势开始的时候就关闭滑动菜单. 同时因为tableView的重用机制, 我们添加在cell上面的截图和滑动菜单, 我们应该在关闭完成的时候移除掉, 从而不影响我们原来的cell的操作.
- (void)resetInitialState {
// 移除kvo监听者
[self removeTableViewObserver];
// 移除tap手势
[self.tableView removeGestureRecognizer:self.tapGesture];
// 移除添加的view
[self.snapView removeFromSuperview];
self.snapView = nil;
[self.overlayerContentView removeFromSuperview];
self.overlayerContentView = nil;
self.leftView = nil;
self.rightView = nil;
self.tapGesture = nil;
}
到这里我们实现的使用方便灵活的tableView侧滑菜单就结束了, 那么现在你就可以使用我们实现的这个ZJSwipeTableViewCell
来替代系统原本的侧滑效果了, 当然和我们之前实现的抽屉菜单一样, 你还可以自己实现各种需要的炫酷的动画效果. 我相信充满想象力的你一定实现的比笔者这里的要更炫酷和强大.
注意:这是书籍内容中的一个章节, 作为试读文章, 应该已经算书中涉及到的demo中有难度的实现效果了. 从这一节试读章节可以看出, 书中的所有demo实现的难度都不大.同时你也可以参考所有demo的源码来判断每一节的实现难度, 从而整体评估这种难度的书籍是否需要去阅读, 同时判断我的写作风格是否适合你阅读. 关于书籍的更多说明在这里, 请仔细评估.