一:前言
記得工作中第一次用的刷新控件是svpulltorefresh,用法稍微有點(diǎn)麻煩,而且bug頗多,后來果斷放棄,現(xiàn)在用的是MJRefresh,不管是用法還是bug,都比前一個(gè)好多了,但是不久前也遇到了一個(gè)致命的bug,有好些情況下會(huì)導(dǎo)致MJRefresh陷入一個(gè)死循環(huán),導(dǎo)致不斷的刷新,只能重啟軟件才行。MJRefresh工程比較龐大,找到了bug也很難修改,然后還是決定自己寫一個(gè),系統(tǒng)提供的UIRefreshControl我認(rèn)為是最好的,缺點(diǎn)是不提供自定義UI的方法,那么我就自己基于它來自定義UI。我不是一開始就決定繼承于UIRefreshControl,我同時(shí)也寫了一個(gè)繼承與UIView的control,兩個(gè)進(jìn)行對(duì)比,發(fā)現(xiàn)使用UIview會(huì)有很多弊端,這種弊端在一些復(fù)雜特殊的情況下一下子就暴露出來了,而且很難解決,當(dāng)然,正常狀態(tài)下是沒什么問題的,有興趣的同學(xué)倒是可以去試一試。本demo供大家學(xué)習(xí)和參考,如有發(fā)現(xiàn)bug,還請(qǐng)issues 我。
二: 了解 UIRefreshControl
- 基本使用方法
//初始化一個(gè)control
UIRefreshControl *control = [[UIRefreshControl alloc] init];
//給control 添加一個(gè)刷新方法
[control addTarget:self action:@selector(refreshAction) forControlEvents:UIControlEventValueChanged];
//把control 添加到 tableView
[self.tableView addSubview:control];
存在的問題
- 刷新時(shí)的動(dòng)畫是一個(gè)灰色小菊花,很多情況下不符合app的刷新動(dòng)畫效果
- 經(jīng)過多次反復(fù)測(cè)試,下拉的偏移量達(dá)到130以上才會(huì)觸發(fā)刷新方法,很顯然這個(gè)也不符合,一般的刷新控件的高度60左右,所以下拉的偏移量達(dá)到60就可以觸發(fā)刷新的方法了。
自定義控件的思路
- 去掉默認(rèn)的動(dòng)畫效果
- 自定義自己的動(dòng)畫效果
- 改變滿足刷新時(shí)的條件
三:FMRefreshControl
- 先看一下我寫完的這個(gè)控件的使用方法
FMRefreshControl *control = [[FMRefreshControl alloc] initWithTargrt:self refreshAction:@selector(refreshAction)];
[self.tableView addSubview:control];
兩行代碼,用法比系統(tǒng)的還要稍微簡(jiǎn)單一點(diǎn)。
- 再看一下效果

四:思路與代碼
1. 關(guān)于 UIRefreshControl 的幾個(gè)注意點(diǎn),通過frame無法修改它的高度,修改高度目前只找到一種方法,先添加到 superViwe,再執(zhí)行
[[_control.subviews objectAtIndex:0] setFrame:CGRectMake(0, 0, _control.bounds.size.width, 30)];
一開始我是想改變它的高度是否就能改變它的觸發(fā)刷新的偏移量,然后我找到了這個(gè)方法可以修改它的高度,但實(shí)際上改變了高度還是無法改變觸發(fā)下拉刷新的偏移量,所以我們需要自定義去觸發(fā)刷新這個(gè)動(dòng)作的時(shí)機(jī)。
2.手動(dòng)去觸發(fā)刷新動(dòng)作也有幾個(gè)注意點(diǎn),我們是根據(jù)偏移量去觸發(fā)刷新,但是僅僅靠這一個(gè)動(dòng)作是不夠的,還需要一個(gè)條件,那就是用戶手指響應(yīng)過屏幕,簡(jiǎn)單地說,先定義一個(gè)變量,如果用戶觸摸過屏幕,就把變量置為YES,然后再判斷用戶手指離開時(shí)是否達(dá)到了觸發(fā)刷新的偏移量,如果兩個(gè)條件都滿足,就觸發(fā)刷新,刷新完把變量置為NO,如果不滿足,就不觸發(fā),也把變量置為NO。這樣就避免了UIScrollow 因偏移量變動(dòng)而導(dǎo)致非人為的刷新。
3. 進(jìn)入代碼階段
FMRefreshControl *control = [[FMRefreshControl alloc] initWithTargrt:self refreshAction:@selector(refreshAction)];
[self.tableView addSubview:control];
初始化的時(shí)候賦一個(gè) target 和 一個(gè) action,當(dāng)滿足條件的時(shí)候,我們需要知道讓誰去執(zhí)行刷新方法,有這兩個(gè)參數(shù)足夠,當(dāng)執(zhí)行到第二行 addSubView的時(shí)候,我們需要在control內(nèi)部實(shí)現(xiàn)這個(gè)方法:
- (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];
}
}
這樣,我們就知道當(dāng)前這個(gè)control被添加到哪個(gè)父視圖上了,為了安全及代碼的嚴(yán)謹(jǐn),先判斷父視圖是否屬于
UIScrollView,如果是,就用KVO監(jiān)聽contentOffset屬性,這樣便能知道用戶滑動(dòng)的偏移量。
這里我定義了3種狀態(tài):
typedef NS_ENUM(NSInteger, FMRefreshState) {
FMRefreshStateNormal = 0, /** 普通狀態(tài) */
FMRefreshStatePulling, /** 釋放刷新狀態(tài) */
FMRefreshStateRefreshing, /** 正在刷新 */
};
以及切換狀態(tài)后UI的切換和方法的觸發(fā):
- (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 停止動(dòng)畫,切換到FMRefreshStatePulling 開始動(dòng)畫,達(dá)到這個(gè)狀態(tài),說明用戶已經(jīng)達(dá)到了刷新的偏移量,此時(shí)松手便可刷新,切換到FMRefreshStateRefreshing,如果此時(shí)往回滑動(dòng),小于臨界值,那么狀態(tài)重新切回FMRefreshStateNormal。
滿足刷新條件,則便可執(zhí)行以下方法:
- (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
}
下面看最關(guān)鍵的KVO方法,也是這里面最復(fù)雜的邏輯處理代碼:
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSString *,id> *)change context:(void *)context {
//isDragging 屬性是指用戶手指是否在拖動(dòng)
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;
}
}
//拖動(dòng)的偏移量,轉(zhuǎn)換成正數(shù)
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);
}
這里最重要的就是處理兩點(diǎn):1. 根據(jù)偏移量和用戶手指的拖動(dòng)來切換狀態(tài),2. control上面的子視圖需要我們根據(jù)偏移量來實(shí)時(shí)更新。
還有一種情況,上面也提到過,用戶先滑動(dòng)到FMRefreshStatePulling狀態(tài),然后又往回滑動(dòng),此時(shí)的偏移量在0-FMRefreshStatePulling狀態(tài)的偏移量之間,此時(shí)調(diào)用自身的 endRefreshing偏移量不會(huì)復(fù)原,還需要我們自己處理,看了幾個(gè)老外寫的自定義刷新控件,他們都沒修復(fù)這個(gè)bug。他們也沒封裝,全部代碼寫在了控制器里,什么都沒有改變,只是實(shí)現(xiàn)了一個(gè)動(dòng)畫效果,還多了個(gè)bug,動(dòng)畫效果倒是不錯(cuò)的。有興趣的可以參考一番:
https://www.jackrabbitmobile.com/app-development/ios-custom-pull-to-refresh-contro/
https://possiblemobile.com/2014/05/ios-custom-pull-to-refresh/
- (void)endRefreshing {
if (self.currentStatus != FMRefreshStateRefreshing) {
return;
}
self.currentStatus = FMRefreshStateNormal;
[super endRefreshing];
//在執(zhí)行刷新的狀態(tài)中,用戶手動(dòng)拖動(dòng)到 nornal 狀態(tài)的 offset,[super endRefreshing] 無法回到初始位置,所以手動(dòng)設(shè)置
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];
}
});
}
最后還有一點(diǎn)不要忘記 dealloc移除監(jiān)聽:
- (void)dealloc {
[self.superScrollView removeObserver:self forKeyPath:@"contentOffset"];
}
整篇文章從上至下是按照整個(gè)完整的思路寫下來的,先是提出遇到的問題以及難點(diǎn),然后最后的代碼和思路也是由外至內(nèi)一路寫下來,希望方便大家閱讀。這是上篇,下拉刷新的,還有下篇,上拉加載,過兩天寫,demo中已經(jīng)有了,不過就是還沒優(yōu)化。