iOS 自定義卡片式控制元件:QiCardView

QiShare發表於2019-02-11

級別: ★★☆☆☆
標籤:「iOS」「卡片式控制元件」「QiCardView」
作者: MrLiuQ
審校: QiShare團隊


前言:因專案中需求,需要做一個卡片式控制元件。故QiCardView誕生了。

首先,先來看一下QiCardView的效果圖:

iOS 自定義卡片式控制元件:QiCardView

從命名來看,QiCardView,顧名思義,是一個可定製的卡片式UI控制元件。 從設計來看,QiCardView仿照UITableView的設計,支援cell複用,節省了資源。

話不多說,先來看下整體架構~

一、QiCardView整體架構設計

架構層面仿照了UITableView的設計,採用了cell複用策略。 在此基礎上,融入了一些手勢操作,更加富有互動性。

上架構圖:

iOS 自定義卡片式控制元件:QiCardView

兩個主類分別為QiCardViewQiCardViewCell。(仿照UITableView+UITableViewCell的設計)

  • QiCardView 下有兩個代理:QiCardViewDataSourceQiCardViewDelegate。(與UITableView的代理方法類似)
  • QiCardViewCell 下有一個代理:QiCardViewCellDelegate。(這個代理可以不關心,主要目的是輔助QiCardView裡的一些處理邏輯)

二、如何自定義使用QiCardView?

Cell自定義很簡單,只要新建一個類(例如:QiCardViewItemCell)繼承自QiCardViewCell即可。

在Controller中,基本使用上幾乎與UITableView類似。

  • 初始化CardView方法:

在上Demo之前,先介紹幾個可以自定義的配置屬性:

屬性 型別 介紹
visibleCount NSInteger 卡片Cell可見數量(預設3)。因為有複用策略,所以即實際建立的Cell數量。
lineSpacing CGFloat 行間距(預設10.0,可自行計算scale比例來做間距)
interitemSpacing CGFloat 列間距(預設10.0,可自行計算scale比例來做間距)
maxAngle CGFloat 側滑最大角度(預設15°)。值約小越容易劃出,越大約不好劃出。
maxRemoveDistance CGFloat 最大移除距離(預設螢幕的1/4),滑動距離不夠時歸位。
isAlpha CGFloat cell是否需要漸變透明度。(預設YES)
- (void)initViews {
    
    _cardView = [[QiCardView alloc] initWithFrame:CGRectMake(25.0, 150.0, self.view.frame.size.width - 50.0, 420.0)];
    _cardView.backgroundColor = [UIColor lightGrayColor];//!< 為了指出carddView的區域,指明背景色
    _cardView.dataSource = self;
    _cardView.delegate = self;
    _cardView.visibleCount = 4;
    _cardView.lineSpacing = 15.0;
    _cardView.interitemSpacing = 10.0;
    _cardView.maxAngle = 10.0;
    _cardView.isAlpha = YES;
    _cardView.maxRemoveDistance = 100.0;
    _cardView.layer.cornerRadius = 10.0;
    [_cardView registerClass:[QiCardItemCell class] forCellReuseIdentifier:qiCardCellId];
    [self.view addSubview:_cardView];
}
複製程式碼
  • 資料來源:QiCardViewDataSource
    首先controller要遵守協議:<QiCardViewDataSource>
#pragma mark - QiCardViewDataSource

- (QiCardItemCell *)cardView:(QiCardView *)cardView cellForRowAtIndex:(NSInteger)index {
    
    QiCardItemCell *cell = [cardView dequeueReusableCellWithIdentifier:qiCardCellId];
    cell.cellData = _cellItems[index];
    //...

    return cell;
}

- (NSInteger)numberOfCountInCardView:(UITableView *)cardView {
    return _cellItems.count;
}
複製程式碼
  • 代理:QiCardViewDelegate
    還是首先controller需要遵守協議:<QiCardViewDelegate>
#pragma mark - QiCardViewDelegate

- (void)cardView:(QiCardView *)cardView didRemoveLastCell:(QiCardViewCell *)cell forRowAtIndex:(NSInteger)index {
    [cardView reloadDataAnimated:YES];
}

- (void)cardView:(QiCardView *)cardView didRemoveCell:(QiCardViewCell *)cell forRowAtIndex:(NSInteger)index {
    NSLog(@"didRemoveCell forRowAtIndex = %ld", index);
}

- (void)cardView:(QiCardView *)cardView didDisplayCell:(QiCardViewCell *)cell forRowAtIndex:(NSInteger)index {
    
    NSLog(@"didDisplayCell forRowAtIndex = %ld", index);
}

- (void)cardView:(QiCardView *)cardView didMoveCell:(QiCardViewCell *)cell forMovePoint:(CGPoint)point {
    NSLog(@"move point = %@", NSStringFromCGPoint(point));
}
複製程式碼

三、QiCardView的技術點

3.1 QiCardViewCell複用策略實現

  1. 註冊Cell:
    兩種方式:registerNibregisterClass。 很簡單。
/** 註冊cell方法一:Nib */
- (void)registerNib:(nullable UINib *)nib forCellReuseIdentifier:(NSString *)identifier {
    self.nib = nib;
    self.identifier = identifier;
}

/** 註冊cell方法二:Class */
- (void)registerClass:(nullable Class)cellClass forCellReuseIdentifier:(NSString *)identifier {
    self.cellClass = cellClass;
    self.identifier = identifier;
}
複製程式碼
  1. 獲取快取Cell策略:
    先看快取池中是否有相同ID(identifier)的Cell,有的話,直接返回Cell。
    若快取池中沒有,那麼就new一個新的Cell啦~
/** 獲取快取cell */
- (__kindof QiCardViewCell *)dequeueReusableCellWithIdentifier:(NSString *)identifier {
    for (QiCardViewCell *cell in self.reusableCells) {
        if ([cell.reuseIdentifier isEqualToString:identifier]) {
            [self.reusableCells removeObject:cell];
            
            return cell;
        }
    }
    if (self.nib) {
        QiCardViewCell *cell = [[self.nib instantiateWithOwner:nil options:nil] lastObject];
        cell.reuseIdentifier = identifier;
        
        return cell;
    } else if (self.cellClass) { // 註冊class
        QiCardViewCell *cell = [[self.cellClass alloc] initWithReuseIdentifier:identifier];
        cell.reuseIdentifier = identifier;
        
        return cell;
    }
    return nil;
}
複製程式碼
  1. 當cell走DidRemoveFromSuperView方法時,把cell加入快取池。
- (void)cardViewCellDidRemoveFromSuperView:(QiCardViewCell *)cell {

    //...    

    [self.reusableCells addObject:cell];

    //...
}
複製程式碼

3.2 cell重疊透明度漸變的實現

  1. 首先宣告瞭一個靜態變數:moveCount來記錄翻卡次數。(以便將cell的index與卡片的index邏輯關聯)
static int moveCount = 0;//!< 記錄翻頁次數
複製程式碼
  1. 邏輯:每個CardCell 在 “remove from super view” 的時候 moveCount+1。
#pragma mark - QiCardViewCellDelagate

- (void)cardViewCellDidRemoveFromSuperView:(QiCardViewCell *)cell {
    
    moveCount++;
    
    //....
}
複製程式碼
  1. 邏輯:在reload方法中,需要將moveCount置0。(很好理解,reload時,moveCount需要重新開始計算)
- (void)reloadDataAnimated:(BOOL)animated {
    
    moveCount = 0;//!< 漸變需要
    
   //...
}
複製程式碼
  1. 關鍵邏輯:在每次更新佈局時,設定每個Cell的漸變值(即alpha
/** 更新佈局(動畫) */
- (void)updateLayoutVisibleCellsWithAnimated:(BOOL)animated {
    
    //...

    if (_isAlpha) {
        BOOL isTopCell = (i == _currentIndex - moveCount);
        if (isTopCell) {//!< 如果是最上面的Cell就透明度為1
            cell.alpha = 1.0;
         } else {
            cell.alpha = (i + 1.9) * 1.0/self.visibleCells.count;
        }
    }

    //...

}
複製程式碼

3.3 手勢操作實現

這部分主要是手勢+動畫。
細節比較多,小而雜。
詳細邏輯,請見原始碼

#define Qi_SNAPSHOTVIEW_TAG 999
#define Qi_DEGREES_TO_RADIANS(angle) (angle / 180.0 * M_PI)

- (void)panGestureRecognizer:(UIPanGestureRecognizer*)pan {
    
    switch (pan.state) {
        case UIGestureRecognizerStateBegan:
            self.currentPoint = CGPointZero;
            break;
        case UIGestureRecognizerStateChanged: {
            CGPoint movePoint = [pan translationInView:pan.view];
            self.currentPoint = CGPointMake(self.currentPoint.x + movePoint.x , self.currentPoint.y + movePoint.y);
            
            CGFloat moveScale = self.currentPoint.x / self.maxRemoveDistance;
            if (ABS(moveScale) > 1.0) {
                moveScale = (moveScale > 0) ? 1.0 : -1.0;
            }
            CGFloat angle = Qi_DEGREES_TO_RADIANS(self.maxAngle) * moveScale;
            CGAffineTransform transRotation = CGAffineTransformMakeRotation(angle);
            self.transform = CGAffineTransformTranslate(transRotation, self.currentPoint.x, self.currentPoint.y);
            
            if (self.cell_delegate && [self.cell_delegate respondsToSelector:@selector(cardViewCellDidMoveFromSuperView:forMovePoint:)]) {
                [self.cell_delegate cardViewCellDidMoveFromSuperView:self forMovePoint:self.currentPoint];
            }
            [pan setTranslation:CGPointZero inView:pan.view];
        }
            break;
        case UIGestureRecognizerStateEnded:
            [self didPanStateEnded];
            break;
        case UIGestureRecognizerStateCancelled:
        case UIGestureRecognizerStateFailed:
            [self restoreCellLocation];
            break;
        default:
            break;
    }
}

// 手勢結束操作(不考慮上下位移)
- (void)didPanStateEnded {
    // 右滑移除
    if (self.currentPoint.x > self.maxRemoveDistance) {
        __block UIView *snapshotView = [self snapshotViewAfterScreenUpdates:NO];
        snapshotView.transform = self.transform;
        [self.superview.superview addSubview:snapshotView];
        [self didCellRemoveFromSuperview];
        
        CGFloat endCenterX = [UIScreen mainScreen].bounds.size.width/2 + self.frame.size.width * 1.5;
        [UIView animateWithDuration:Qi_DefaultDuration animations:^{
            CGPoint center = self.center;
            center.x = endCenterX;
            snapshotView.center = center;
        } completion:^(BOOL finished) {
            [snapshotView removeFromSuperview];
        }];
    }
    // 左滑移除
    else if (self.currentPoint.x < -self.maxRemoveDistance) {
        __block UIView *snapshotView = [self snapshotViewAfterScreenUpdates:NO];
        snapshotView.transform = self.transform;
        [self.superview.superview addSubview:snapshotView];
        [self didCellRemoveFromSuperview];
        
        CGFloat endCenterX = -([UIScreen mainScreen].bounds.size.width/2 + self.frame.size.width);
        [UIView animateWithDuration:Qi_DefaultDuration animations:^{
            CGPoint center = self.center;
            center.x = endCenterX;
            snapshotView.center = center;
        } completion:^(BOOL finished) {
            [snapshotView removeFromSuperview];
        }];
    }
    // 滑動距離不夠歸位
    else {
        [self restoreCellLocation];
    }
}

// 還原卡片位置
- (void)restoreCellLocation {
    
    [UIView animateWithDuration:Qi_SpringDuration delay:0
         usingSpringWithDamping:Qi_SpringWithDamping
          initialSpringVelocity:Qi_SpringVelocity
                        options:UIViewAnimationOptionCurveEaseOut
                     animations:^{
                         self.transform = CGAffineTransformIdentity;
                     } completion:nil];
}

// 卡片移除處理
- (void)didCellRemoveFromSuperview {
    
    self.transform = CGAffineTransformIdentity;
    [self removeFromSuperview];
    if ([self.cell_delegate respondsToSelector:@selector(cardViewCellDidRemoveFromSuperView:)]) {
        [self.cell_delegate cardViewCellDidRemoveFromSuperView:self];
    }
}

- (void)removeFromSuperviewSwipe:(QiCardCellSwipeDirection)direction {
    
    switch (direction) {
        case QiCardCellSwipeDirectionLeft: {
            [self removeFromSuperviewLeft];
        }
            break;
        case QiCardCellSwipeDirectionRight: {
            [self removeFromSuperviewRight];
        }
            break;
        default:
            break;
    }
}

// 向左邊移除動畫
- (void)removeFromSuperviewLeft {
    __block UIView *snapshotView = [self snapshotViewAfterScreenUpdates:NO];
    [self.superview.superview addSubview:snapshotView];
    [self didCellRemoveFromSuperview];
    
    CGAffineTransform transRotation = CGAffineTransformMakeRotation(-Qi_DEGREES_TO_RADIANS(self.maxAngle));
    CGAffineTransform transform = CGAffineTransformTranslate(transRotation, 0, self.frame.size.height/4.0);
    CGFloat endCenterX = -([UIScreen mainScreen].bounds.size.width/2 + self.frame.size.width);
    [UIView animateWithDuration:Qi_DefaultDuration animations:^{
        CGPoint center = self.center;
        center.x = endCenterX;
        snapshotView.center = center;
        snapshotView.transform = transform;
    } completion:^(BOOL finished) {
        [snapshotView removeFromSuperview];
    }];
}

// 向右邊移除動畫
- (void)removeFromSuperviewRight {
    __block UIView *snapshotView = [self snapshotViewAfterScreenUpdates:NO];
    snapshotView.frame = self.frame;
    [self.superview.superview addSubview:snapshotView];
    [self didCellRemoveFromSuperview];
    
    CGAffineTransform transRotation = CGAffineTransformMakeRotation(Qi_DEGREES_TO_RADIANS(self.maxAngle));
    CGAffineTransform transform = CGAffineTransformTranslate(transRotation, 0, self.frame.size.height/4.0);
    CGFloat endCenterX = [UIScreen mainScreen].bounds.size.width/2 + self.frame.size.width * 1.5;
    [UIView animateWithDuration:Qi_DefaultDuration animations:^{
        CGPoint center = self.center;
        center.x = endCenterX;
        snapshotView.center = center;
        snapshotView.transform = transform;
    } completion:^(BOOL finished) {
        [snapshotView removeFromSuperview];
    }];
}
複製程式碼

四、未來可能優化的點

  • 設計層面:如果將手勢操作融入QiCardView中,將QiCardViewCell變成純粹的Cell,會不會更好。(思考中)
  • 應用層面:目前只支援一個ID的Cell重用,未來渴望擴充成多個ID的Cell都可重用。(PS:因為只存了一個ID,後續考慮存陣列,以及對應的Cell快取池陣列。以此猜測UITableView的內部實現。)

原始碼:QiCardView原始碼


小編微信:可加並拉入《QiShare技術交流群》。

iOS 自定義卡片式控制元件:QiCardView

關注我們的途徑有:
QiShare(簡書)
QiShare(掘金)
QiShare(知乎)
QiShare(GitHub)
QiShare(CocoaChina)
QiShare(StackOverflow)
QiShare(微信公眾號)

推薦文章:
iOS Wireshark抓包
iOS Charles抓包
初探TCP
IP、UDP初探
奇舞週刊

相關文章