Snip20161104_49.png
我相信購買這本書的朋友,一定使用過簡書這個APP了。那么肯定見過簡書上面漂亮的tableView滑動菜單了。系統的tableView是只需要配置幾個代理方法, 就可以實現cell的左右側滑菜單的. 一般會被用來作為編輯,刪除等使用. 但是雖然在使用上挺方便的. 不過系統提供的的樣式局限性很大, 就像QQ的側滑樣式, 只能顯示字符并且動畫效果很單一. 不過, 我們實際開發中會遇到的可能并不僅僅是這么簡單, 可能是上面圖片顯示的這樣本節中就分享給朋友們吧, 也許不久的開發中你就會遇到類似的需求了, 那就再好不過了.
本節中, 我們將實現自定義的tableViewCell的側滑菜單, 并且實現四種常見的動畫效果, 同時簡書炫酷的側滑效果也一并實現了.
這個看上去比較小的需求, 筆者最初嘗試實現的時候仍然是不知道如何下手去完成, 經過一段時間的考慮后才有一些想法. 后來大概使用了兩種方式來實現. 因為在實現這個需求之前筆者自己實現過抽屜菜單的需求(我們上一節中也已經實現了), 最初想到的就是在每一個cell類似抽屜菜單一樣, 增加兩個左右的抽屜菜單, 然后打開和關閉就和我們處理抽屜菜單一樣, 最終是順利的實現了這個需求. 用上去還是比較方便. 后來再次回頭研究的時候, 想到了另外一種比較方便的實現方法. 下面我們就使用這種方法來實現了.
swipeTableViewCell.gif
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;
}
Snip20161104_50.png
到這里我們實現的使用方便靈活的tableView側滑菜單就結束了, 那么現在你就可以使用我們實現的這個ZJSwipeTableViewCell
來替代系統原本的側滑效果了, 當然和我們之前實現的抽屜菜單一樣, 你還可以自己實現各種需要的炫酷的動畫效果. 我相信充滿想象力的你一定實現的比筆者這里的要更炫酷和強大.
注意:這是書籍內容中的一個章節, 作為試讀文章, 應該已經算書中涉及到的demo中有難度的實現效果了. 從這一節試讀章節可以看出, 書中的所有demo實現的難度都不大.同時你也可以參考所有demo的源碼來判斷每一節的實現難度, 從而整體評估這種難度的書籍是否需要去閱讀, 同時判斷我的寫作風格是否適合你閱讀. 關于書籍的更多說明在這里, 請仔細評估.