一:前言
記得工作中第一次用的重新整理控制元件是svpulltorefresh
,用法稍微有點麻煩,而且bug頗多,後來果斷放棄,現在用的是MJRefresh
,不管是用法還是bug,都比前一個好多了,但是不久前也遇到了一個致命的bug,有好些情況下會導致MJRefresh
陷入一個死迴圈,導致不斷的重新整理,只能重啟軟體才行。MJRefresh
工程比較龐大,找到了bug也很難修改,然後還是決定自己寫一個,系統提供的UIRefreshControl
我認為是最好的,缺點是不提供自定義UI的方法,那麼我就自己基於它來自定義UI。我不是一開始就決定繼承於UIRefreshControl
,我同時也寫了一個繼承與UIView
的control,兩個進行對比,發現使用UIview
會有很多弊端,這種弊端在一些複雜特殊的情況下一下子就暴露出來了,而且很難解決,當然,正常狀態下是沒什麼問題的,有興趣的同學倒是可以去試一試。本demo供大家學習和參考,如有發現bug,還請issues 我。
二: 瞭解 UIRefreshControl
- 基本使用方法
1 2 3 4 5 6 |
//初始化一個control UIRefreshControl *control = [[UIRefreshControl alloc] init]; //給control 新增一個重新整理方法 [control addTarget:self action:@selector(refreshAction) forControlEvents:UIControlEventValueChanged]; //把control 新增到 tableView [self.tableView addSubview:control]; |
- 存在的問題
-
- 重新整理時的動畫是一個灰色小菊花,很多情況下不符合app的重新整理動畫效果
-
- 經過多次反覆測試,下拉的偏移量達到130以上才會觸發重新整理方法,很顯然這個也不符合,一般的重新整理控制元件的高度60左右,所以下拉的偏移量達到60就可以觸發重新整理的方法了。
-
- 自定義控制元件的思路
-
- 去掉預設的動畫效果
-
- 自定義自己的動畫效果
-
- 改變滿足重新整理時的條件
-
三:FMRefreshControl
- 先看一下我寫完的這個控制元件的使用方法
1 2 3 |
FMRefreshControl *control = [[FMRefreshControl alloc] initWithTargrt:self refreshAction:@selector(refreshAction)]; [self.tableView addSubview:control]; |
兩行程式碼,用法比系統的還要稍微簡單一點。
- 再看一下效果
四:思路與程式碼
1. 關於 UIRefreshControl
的幾個注意點,通過frame無法修改它的高度,修改高度目前只找到一種方法,先新增到 superViwe
,再執行
1 |
[[_control.subviews objectAtIndex:0] setFrame:CGRectMake(0, 0, _control.bounds.size.width, 30)]; |
一開始我是想改變它的高度是否就能改變它的觸發重新整理的偏移量,然後我找到了這個方法可以修改它的高度,但實際上改變了高度還是無法改變觸發下拉重新整理的偏移量,所以我們需要自定義去觸發重新整理這個動作的時機。
2.手動去觸發重新整理動作也有幾個注意點,我們是根據偏移量去觸發重新整理,但是僅僅靠這一個動作是不夠的,還需要一個條件,那就是使用者手指響應過螢幕,簡單地說,先定義一個變數,如果使用者觸控過螢幕,就把變數置為YES,然後再判斷使用者手指離開時是否達到了觸發重新整理的偏移量,如果兩個條件都滿足,就觸發重新整理,重新整理完把變數置為NO,如果不滿足,就不觸發,也把變數置為NO。這樣就避免了UIScrollow 因偏移量變動而導致非人為的重新整理。
3. 進入程式碼階段
1 2 |
FMRefreshControl *control = [[FMRefreshControl alloc] initWithTargrt:self refreshAction:@selector(refreshAction)]; [self.tableView addSubview:control]; |
初始化的時候賦一個 target 和 一個 action,當滿足條件的時候,我們需要知道讓誰去執行重新整理方法,有這兩個引數足夠,當執行到第二行 addSubView的時候,我們需要在control內部實現這個方法:
1 2 3 4 5 6 7 8 9 |
- (void)willMoveToSuperview:(UIView *)newSuperview { [super willMoveToSuperview:newSuperview]; if ([newSuperview isKindOfClass:[UIScrollView class]]) { self.superScrollView = (UIScrollView *)newSuperview; [self.superScrollView addObserver:self forKeyPath:@"contentOffset" options:NSKeyValueObservingOptionNew context:nil]; } } |
這樣,我們就知道當前這個control被新增到哪個父檢視上了,為了安全及程式碼的嚴謹,先判斷父檢視是否屬於
UIScrollView
,如果是,就用KVO監聽contentOffset
屬性,這樣便能知道使用者滑動的偏移量。
這裡我定義了3種狀態:
1 2 3 4 5 |
typedef NS_ENUM(NSInteger, FMRefreshState) { FMRefreshStateNormal = 0, /** 普通狀態 */ FMRefreshStatePulling, /** 釋放重新整理狀態 */ FMRefreshStateRefreshing, /** 正在重新整理 */ }; |
以及切換狀態後UI的切換和方法的觸發:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
- (void)setCurrentStatus:(FMRefreshState)currentStatus { _currentStatus = currentStatus; switch (_currentStatus) { case FMRefreshStateNormal: NSLog(@"切換到Normal"); [self.imageView stopAnimating]; self.label.text = FM_Refresh_normal_title; [self.label sizeToFit]; self.imageView.image = [UIImage imageNamed:@"refresh_1"]; break; case FMRefreshStatePulling: NSLog(@"切換到Pulling"); self.label.text = FM_Refresh_pulling_title; [self.label sizeToFit]; self.imageView.animationImages = self.refreshingImages; self.imageView.animationDuration = 1.5; [self.imageView startAnimating]; break; case FMRefreshStateRefreshing: NSLog(@"切換到Refreshing"); self.label.text = FM_Refresh_Refreshing_title; [self.label sizeToFit]; [self beginRefreshing]; self.imageView.animationImages = self.refreshingImages; self.imageView.animationDuration = 1.5; [self.imageView startAnimating]; [self doRefreshAction]; break; } } |
切換到FMRefreshStateNormal
停止動畫,切換到FMRefreshStatePulling
開始動畫,達到這個狀態,說明使用者已經達到了重新整理的偏移量,此時鬆手便可重新整理,切換到FMRefreshStateRefreshing
,如果此時往回滑動,小於臨界值,那麼狀態重新切回FMRefreshStateNormal
。
滿足重新整理條件,則便可執行以下方法:
1 2 3 4 5 6 7 8 9 |
- (void)doRefreshAction { #pragma clang diagnostic push #pragma clang diagnostic ignored "-Warc-performSelector-leaks" if (self.refreshTarget && [self.refreshTarget respondsToSelector:self.refreshAction]) [self.refreshTarget performSelector:self.refreshAction]; #pragma clang diagnostic pop } |
下面看最關鍵的KVO方法,也是這裡面最複雜的邏輯處理程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSString *,id> *)change context:(void *)context { //isDragging 屬性是指使用者手指是否在拖動 if (self.superScrollView.isDragging && !self.isRefreshing) { if (!self.originalOffsetY) { self.originalOffsetY = -self.superScrollView.contentInset.top; } CGFloat normalPullingOffset = self.originalOffsetY - k_FMRefresh_Height; if (self.currentStatus == FMRefreshStatePulling && self.superScrollView.contentOffset.y > normalPullingOffset) { self.currentStatus = FMRefreshStateNormal; } else if (self.currentStatus == FMRefreshStateNormal && self.superScrollView.contentOffset.y < normalPullingOffset) { self.currentStatus = FMRefreshStatePulling; } } else if(!self.superScrollView.isDragging){ if (self.currentStatus == FMRefreshStatePulling) { self.currentStatus = FMRefreshStateRefreshing; } } //拖動的偏移量,轉換成正數 CGFloat pullDistance = -self.frame.origin.y; self.backgroundView.frame = CGRectMake(0, 0, k_FMRefresh_Width, pullDistance); CGFloat totalWidth = 35 + 20 + self.label.bounds.size.width; CGFloat imageViewX = (k_FMRefresh_Width - totalWidth)/2; self.imageView.frame = CGRectMake(imageViewX, -k_FMRefresh_Height+pullDistance+(k_FMRefresh_Height - self.imageView.bounds.size.height)/2, self.imageView.frame.size.width, self.imageView.frame.size.height); self.label.frame = CGRectMake(imageViewX + 35 + 20, -k_FMRefresh_Height + pullDistance + (k_FMRefresh_Height - self.label.bounds.size.height)/2, self.label.frame.size.width, self.label.frame.size.height); } |
這裡最重要的就是處理兩點:1. 根據偏移量和使用者手指的拖動來切換狀態,2. control上面的子檢視需要我們根據偏移量來實時更新。
還有一種情況,上面也提到過,使用者先滑動到FMRefreshStatePulling
狀態,然後又往回滑動,此時的偏移量在0-FMRefreshStatePulling
狀態的偏移量之間,此時呼叫自身的 endRefreshing
偏移量不會復原,還需要我們自己處理,看了幾個老外寫的自定義重新整理控制元件,他們都沒修復這個bug。他們也沒封裝,全部程式碼寫在了控制器裡,什麼都沒有改變,只是實現了一個動畫效果,還多了個bug,動畫效果倒是不錯的。有興趣的可以參考一番:
https://www.jackrabbitmobile.com/app-development/ios-custom-pull-to-refresh-contro/
https://possiblemobile.com/2014/05/ios-custom-pull-to-refresh/
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
- (void)endRefreshing { if (self.currentStatus != FMRefreshStateRefreshing) { return; } self.currentStatus = FMRefreshStateNormal; [super endRefreshing]; //在執行重新整理的狀態中,使用者手動拖動到 nornal 狀態的 offset,[super endRefreshing] 無法回到初始位置,所以手動設定 dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ if(self.superScrollView.contentOffset.y >= self.originalOffsetY - k_FMRefresh_Height && self.superScrollView.contentOffset.y <= self.originalOffsetY) { CGPoint offset = self.superScrollView.contentOffset; offset.y = self.originalOffsetY; [self.superScrollView setContentOffset:offset animated:YES]; } }); } |
最後還有一點不要忘記 dealloc
移除監聽:
1 2 3 |
- (void)dealloc { [self.superScrollView removeObserver:self forKeyPath:@"contentOffset"]; } |
整篇文章從上至下是按照整個完整的思路寫下來的,先是提出遇到的問題以及難點,然後最後的程式碼和思路也是由外至內一路寫下來,希望方便大家閱讀。這是上篇,下拉重新整理的,還有下篇,上拉載入,過兩天寫,demo中已經有了,不過就是還沒優化。
domo地址:https://github.com/suifengqjn/FMRefreshControl
打賞支援我寫出更多好文章,謝謝!
打賞作者
打賞支援我寫出更多好文章,謝謝!
任選一種支付方式