iOS 自定義卡片式控制元件:QiCardView
級別: ★☆☆☆☆
標籤:「iOS」「卡片式控制元件」「QiCardView」
作者:MrLiuQ
審校:QiShare團隊
前言:因專案中需求,需要做一個卡片式控制元件。故QiCardView誕生了。
首先,先來看一下QiCardView的效果圖:

從命名來看,QiCardView,顧名思義,是一個可定製的卡片式UI控制元件。 從設計來看,QiCardView仿照UITableView的設計,支援cell複用,節省了資源。
話不多說,先來看下整體架構~
一、QiCardView整體架構設計
架構層面仿照了 UITableView
的設計,採用了cell複用策略。 在此基礎上,融入了一些手勢操作,更加富有互動性。
上架構圖:

兩個主類分別為 QiCardView
與 QiCardViewCell
。(仿照 UITableView
+ UITableViewCell
的設計)
-
QiCardView
下有兩個代理:QiCardViewDataSource
、QiCardViewDelegate
。(與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複用策略實現
-
註冊Cell:
兩種方式:
registerNib
、registerClass
。 很簡單。
/** 註冊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; } 複製程式碼
-
獲取快取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; } 複製程式碼
- 當cell走
DidRemoveFromSuperView
方法時,把cell加入快取池。
- (void)cardViewCellDidRemoveFromSuperView:(QiCardViewCell *)cell { //... [self.reusableCells addObject:cell]; //... } 複製程式碼
3.2 cell重疊透明度漸變的實現
- 首先聲明瞭一個靜態變數:
moveCount
來記錄翻卡次數。(以便將cell的index與卡片的index邏輯關聯)
static int moveCount = 0;//!< 記錄翻頁次數 複製程式碼
- 邏輯:每個CardCell 在 “remove from super view” 的時候 moveCount+1。
#pragma mark - QiCardViewCellDelagate - (void)cardViewCellDidRemoveFromSuperView:(QiCardViewCell *)cell { moveCount++; //.... } 複製程式碼
- 邏輯:在reload方法中,需要將moveCount置
0
。(很好理解,reload時,moveCount需要重新開始計算)
- (void)reloadDataAnimated:(BOOL)animated { moveCount = 0;//!< 漸變需要 //... } 複製程式碼
- 關鍵邏輯:在每次更新佈局時,設定每個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技術交流群》。

關注我們的途徑有:
QiShare(微信公眾號)