(iOS)可以這樣來玩玩tableViewCell的滑動選單

ZeroJ發表於2016-11-04

(iOS)可以這樣來玩玩tableViewCell的滑動選單

系統的tableView是隻需要配置幾個代理方法, 就可以實現cell的左右側滑選單的. 一般會被用來作為編輯,刪除等使用. 但是雖然在使用上挺方便的. 不過系統提供的的樣式侷限性很大, 就像QQ的側滑樣式, 只能顯示字元並且動畫效果很單一. 不過, 我們實際開發中會遇到的可能並不僅僅是這麼簡單, 可能是上面圖片顯示的這樣本節中就分享給朋友們吧, 也許不久的開發中你就會遇到類似的需求了, 那就再好不過了.

本節中, 我們將實現自定義的tableViewCell的側滑選單, 並且實現四種常見的動畫效果, 同時簡書炫酷的側滑效果也一併實現了.

這個看上去比較小的需求, 筆者最初嘗試實現的時候仍然是不知道如何下手去完成, 經過一段時間的考慮後才有一些想法. 後來大概使用了兩種方式來實現. 因為在實現這個需求之前筆者自己實現過抽屜選單的需求(我們上一節中也已經實現了), 最初想到的就是在每一個cell類似抽屜選單一樣, 增加兩個左右的抽屜選單, 然後開啟和關閉就和我們處理抽屜選單一樣, 最終是順利的實現了這個需求. 用上去還是比較方便. 後來再次回頭研究的時候, 想到了另外一種比較方便的實現方法. 下面我們就使用這種方法來實現了.

(iOS)可以這樣來玩玩tableViewCell的滑動選單

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;
}複製程式碼

(iOS)可以這樣來玩玩tableViewCell的滑動選單

到這裡我們實現的使用方便靈活的tableView側滑選單就結束了, 那麼現在你就可以使用我們實現的這個ZJSwipeTableViewCell來替代系統原本的側滑效果了, 當然和我們之前實現的抽屜選單一樣, 你還可以自己實現各種需要的炫酷的動畫效果. 我相信充滿想象力的你一定實現的比筆者這裡的要更炫酷和強大.

注意:
這是書籍內容中的一個章節, 作為試讀文章, 應該已經算書中涉及到的demo中有難度的實現效果了. 從這一節試讀章節可以看出, 書中的所有demo實現的難度都不大.同時你也可以參考所有demo的原始碼來判斷每一節的實現難度, 從而整體評估這種難度的書籍是否需要去閱讀, 同時判斷我的寫作風格是否適合你閱讀. 關於書籍的更多說明在這裡, 請仔細評估.

相關文章