之前在项目中一直用的是李明杰老师的MJRefresh刷新框架,用起来很方便,这两天看了下其源码,于是尝试着自己写一个简易版的刷新框架。
先整理一下思路:
封装表格刷新框架一般要用到分类,UITableView是继承自UIScrollView的,我们干脆建一个基于UIScrollView的分类,着样就可以让所有继承自UIScrollView的视图都可以添加刷新功能。
表格的头部刷新视图和尾部刷新视图,我们可以分别建成两个视图类,一个HeaderView , 一个FooterView。
为了方便,我们可以让HeaderView和FooterView继承自同一个类BaseView,因为它们有相同的功能要完成(监听表格的滑动),也有着共同的属性(比如绑定的方法选择子,方法传递的对象等)。后面会一一讲到。
这样一分析,我们需要新建一下类:
1.UIScrollView + CHERefresh 基于UIScrollView的分类
2.CHEBaseRefreshView 头部刷新视图和尾部刷新视图的父类(继承自UIView)
3.CHEHeaderRefreshView 头部刷新视图(继承CHEBaseRefreshView)
4.CHEFooterRefreshView 尾部刷新视图(继承CHEBaseRefreshView)
(既然是框架嘛,当然要加上前缀啦)
接下来先从CHEBaseRefreshView吧
我们先来定义一下常量(比如头尾视图的高度啊,开始刷新的临界值啊,提示文字啊什么的),后面会用到
#define K_HEADER_REFRESHVIEW_WH 120.0 //头部刷新控件高度
#define K_HEADER_MAXOFFY K_HEADER_REFRESHVIEW_WH / 2.0 //刷新临界值
#define K_FOOTER_REFRESHVIEW_WH 120.0
#define K_FOOTER_MAXOFFY K_FOOTER_REFRESHVIEW_WH / 2.0
#define K_HEAD_NORMAL_TITLE @"下拉即可刷新"
#define K_HEAD_PREPARE_TITLE @"松开立即刷新"
#define K_HEAD_START_TITLE @"正在刷新数据"
#define K_FOOT_NORMAL_TITLE @"上拉即可加载更多"
#define K_FOOT_PREPARE_TITLE @"松开立即加载更多"
#define K_FOOT_START_TITLE @"正在加载更多数据"
然后还需要定义一个很重要的东西,刷新状态的枚举
//刷新状态枚举
typedef enum {
STATE_NORMAL =1, //正常状态
STATE_DIDPREPARETOREFRESH, //预备刷新状态(松开刷新)
STATE_STARTREFRESH, //开始刷新状态
} REFRESHSTATE;
下面是一些属性
@property (nonatomic,strong)UIScrollView *superScrollView; //父视图(表格scrollView)
@property (nonatomic,weak) id selecterTarget; //刷新事件的绑定对象
@property (nonatomic,assign)SEL refreshSelecter; //刷新事件方法选择器
@property (nonatomic,assign)CGFloat superScrollViewContentOffY; //父视图的偏移量
@property (nonatomic,assign)CGSize superScrollViewContentSize; //父视图的大小
@property (nonatomic,assign)REFRESHSTATE headerRefreshState; //记录顶部刷新状态
@property (nonatomic,assign)REFRESHSTATE footerRefreshState; //记录底部刷新状态
需要定义的东西已经差不多了,让我们回到UIScrollView + CHERefresh分类中,这里才是添加刷新功能的入口
为了更加简便,我们可以把头部刷新和尾部刷新放在一个方法里面
-(void)addRefreshWithTarget:(id)target headerSelect:(SEL)headerSelect footerSelect:(SEL)footerSelect
我想一看方法名就很明白了,调用这个方法就可以同时添加上下拉刷新了。参数分别为事件调用对象,上/下拉的事件选择子。如果只想添加其中一个,可以将另一个选择子设为Nill即可。详细实现:
-(void)addRefreshWithTarget:(id)target headerSelect:(SEL)headerSelect footerSelect:(SEL)footerSelect
{
if (headerSelect)
{
//添加头部刷新
CHEHeaderRefreshView *header = [[CHEHeaderRefreshViewalloc]initWithFrame:CGRectMake(0, -K_HEADER_REFRESHVIEW_WH,self.bounds.size.width,K_HEADER_REFRESHVIEW_WH)];
header.refreshSelecter = headerSelect;
header.selecterTarget = target;
header.superScrollView =self;
[selfaddSubview:header];
}
if (footerSelect)
{
//添加尾部刷新
CHEFooterRefreshView *footer = [[CHEFooterRefreshViewalloc]initWithFrame:CGRectMake(0,self.contentSize.height,self.bounds.size.width,K_FOOTER_REFRESHVIEW_WH)];
footer.refreshSelecter = footerSelect;
footer.selecterTarget = target;
footer.superScrollView =self;
footer.scale =1.0;
[selfaddSubview:footer];
}
}
可以看到在这个方法里,我们将头,尾部视图分别添加到了表格的头,尾部。同时保存了事件对象和方法选择子。界面添加上去呢,下面该添加逻辑功能了,先从CHEBaseRefreshView入手。
重写初始化方法,把头部刷新状态和尾部刷新状态初始化为正常状态
-(id)initWithFrame:(CGRect)frame
{
self = [superinitWithFrame:frame];
if (self)
{
//初始化状态均为正常状态
self.headerRefreshState =STATE_NORMAL;
self.footerRefreshState =STATE_NORMAL;
}
return self;
}
接下来就要添加表格的滑动监听了,我们可以在视图将要显示的时候添加
//将要显示在父视图时调用,此时添加KVO监听表格偏移量
-(void)willMoveToSuperview:(UIView *)newSuperview
{
//记录父视图
_superScrollView = (UITableView*)newSuperview;
//添加KVO监听父视图的偏移量
[newSuperview addObserver:selfforKeyPath:@"contentOffset"options:NSKeyValueObservingOptionNewcontext:nil];
}
顾名思义,上面这个方法会再视图将要显示在父视图中时调用,在此时添加KVO监听。
接下来就是要根据表格的滑动距离,判断并更新头,尾部刷新的状态了,考验逻辑的时候到了。
-(void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
{
//获取父视图偏移量
CGFloat offY =_superScrollView.contentOffset.y;
//定义新的头,尾刷新状态(待会要判断状态是否发生改变,没改变就不用更新了,为什么要这样呢,后面会知道)
REFRESHSTATE newHeaderState, newFooterState;
self.superScrollViewContentOffY = offY; //此处用set方法设置contentOffY,方便子类调用set方法,做其他处理
//-----------------------更新头部刷新状态-------------------------
if (self.headerRefreshState ==STATE_STARTREFRESH)
{
//这里注意,当头部处于刷新状态时,就不能根据父视图的偏移量来判断状态了,因为在未停止前,刷新状态一直要保持,直到用户调用停止刷新方法
newHeaderState =STATE_STARTREFRESH;
}
else
{
if (offY <= -K_HEADER_MAXOFFY)//到达临界值时
{
if (_superScrollView.isDragging)
{
//手指未松开,保持预备刷新状态
newHeaderState =STATE_DIDPREPARETOREFRESH;
}
else
{
//手指松开,立即进入开始刷新状态
newHeaderState =STATE_STARTREFRESH;
}
}
else
{
//小余临界值,正常状态
newHeaderState =STATE_NORMAL;
}
}
//如果头部刷新状态发生了改变就更新
if (_headerRefreshState != newHeaderState)
{
self.headerRefreshState = newHeaderState;
}
//*************************
//这里是为了解决尾部刷新的一个BUG,可以先不管
if (_superScrollViewContentSize.height ==0)
{
//当未获取到父视图的大小时,不更新底部状态,直接返回
return;
}
//*************************
//-------------------------更新尾部刷新状态---------------------------
if (self.footerRefreshState ==STATE_STARTREFRESH)
{
//同头部刷新一样
//当底部处于刷新状态时,未结束前一直是刷新状态
newFooterState =STATE_STARTREFRESH;
}
else
{
//这里要用到父视图(表格)的高度,(待会我们会在CHEFooterRefreshView里为其设置)
if (offY +_superScrollView.bounds.size.height >= _superScrollViewContentSize.height +K_FOOTER_MAXOFFY) //到达临界值时
{
if (_superScrollView.isDragging)
{
//手指未松开,保持准备刷新状态
newFooterState =STATE_DIDPREPARETOREFRESH;
}
else
{
//手指松开,立即进入开始刷新状态
newFooterState =STATE_STARTREFRESH;
}
}
else
{
//小余临界值,正常状态
newFooterState =STATE_NORMAL;
}
}
//同头部刷新一样,更新底部状态
if (_footerRefreshState != newFooterState)
{
self.footerRefreshState = newFooterState;
}
令人头疼的一部分已经解决了,我们已经完成及时监控并更新头,尾部的刷新状态了,下面要做的就是根据不同的状态去更新UI,来显示不同的文字或视图了。
先从头部视图CHEHeaderRefreshView开始
先定义一些要显示的控件
@implementation CHEHeaderRefreshView
{
UILabel *_label; //提示标题
UILabel *_timeLabel; //刷新时间
UIImageView *_imageView; //下拉箭头
UIActivityIndicatorView *_activityView;//刷新时的活动指示器
}
在初始化视图时,初始化这些控件
_label = [[UILabelalloc]initWithFrame:CGRectMake(0,self.bounds.size.height - (K_TITLE_WH +K_BORDER +K_TITLE_WH),self.bounds.size.width,K_TITLE_WH)];
_label.backgroundColor = [UIColorclearColor];
_label.textAlignment =NSTextAlignmentCenter;
_label.font = [UIFontsystemFontOfSize:16];
_label.textColor = [UIColorblackColor];
_label.text =K_HEAD_NORMAL_TITLE;
[selfaddSubview:_label];
_timeLabel = [[UILabelalloc]initWithFrame:CGRectMake(0,CGRectGetMaxY(_label.frame),self.bounds.size.width,K_TITLE_WH)];
_timeLabel.backgroundColor = [UIColorclearColor];
_timeLabel.textAlignment =NSTextAlignmentCenter;
_timeLabel.font = [UIFontsystemFontOfSize:14];
_timeLabel.textColor = [UIColorgrayColor];
NSString *time = [[NSUserDefaultsstandardUserDefaults]objectForKey:@"LastRefreshTime"];
_timeLabel.text = time.length >0 ? [NSStringstringWithFormat:@"最后刷新 : %@",time] :@"最近未刷新";
[selfaddSubview:_timeLabel];
CGSize size = [K_HEAD_NORMAL_TITLEsizeWithFont:[UIFontsystemFontOfSize:16]constrainedToSize:CGSizeMake(1000,K_TITLE_WH)];
CGFloat oriX =self.bounds.size.width /2 - size.width /2 -K_TITLE_WH *1.2;
_imageView = [[UIImageViewalloc]initWithFrame:CGRectMake(oriX,_label.frame.origin.y -2.5, K_TITLE_WH *1.2,K_TITLE_WH *1.2)];
_imageView.image = [UIImageimageNamed:@"arrow_icon.png"];
_imageView.contentMode =UIViewContentModeScaleAspectFit;
[selfaddSubview:_imageView];
这里界面部分就不多说了,随自己喜欢的样子去调整吧。
还有一个要做的就是根据不同的状态来改变控件显示不同的样式和内容,那么我们该在哪里写上判断的代码呢,还记得父类里那个头部刷新状态和尾部刷新状态的属性吗,之前已经完成了更新状态的代码了,这里我们只需要重写状态属性的set方法即可,当状态发生改变时,会自动调用的。
//头部刷新状态改变时掉用
-(void)setHeaderRefreshState:(REFRESHSTATE)headerRefreshState
{
//记得要先调用父类的方法,保存刷新状态
[supersetHeaderRefreshState:headerRefreshState];
if (headerRefreshState ==STATE_NORMAL)
{
//正常状态
_label.text =K_HEAD_NORMAL_TITLE;
[_activityViewstopAnimating];
_imageView.hidden =NO;
//旋转箭头图标为正常
[UIViewanimateWithDuration:0.2animations:^{
_imageView.transform =CGAffineTransformIdentity;
}];
}
elseif (headerRefreshState ==STATE_DIDPREPARETOREFRESH)
{
//准备刷新状态
_label.text =K_HEAD_PREPARE_TITLE;
[_activityViewstopAnimating];
_imageView.hidden =NO;
//旋转箭头图标朝上
[UIViewanimateWithDuration:0.2animations:^{
_imageView.transform =CGAffineTransformMakeRotation(M_PI);
}];
}
elseif (headerRefreshState ==STATE_STARTREFRESH)
{
//开始刷新状态
_label.text =K_HEAD_START_TITLE;
_imageView.hidden =YES;
//向之前绑定的对象传递开始刷新消息
objc_msgSend(self.selecterTarget,self.refreshSelecter);
//保存刷新的时间
//实例化一个NSDateFormatter对象
NSDateFormatter *dateFormatter = [[NSDateFormatteralloc]init];
//设定时间格式,这里可以设置成自己需要的格式
[dateFormattersetDateFormat:@"MM-dd HH:mm:ss"];
//用[NSDate date]可以获取系统当前时间
NSString *currentDateStr = [dateFormatterstringFromDate:[NSDatedate]];
_timeLabel.text = [NSStringstringWithFormat:@"最后刷新 : %@",currentDateStr];
[[NSUserDefaultsstandardUserDefaults]setObject:currentDateStrforKey:@"LastRefreshTime"];
[[NSUserDefaultsstandardUserDefaults]synchronize];
//当头部开始刷新时,为头部增加一部分高度,不过此时要先获取底部是否有增加的高度(因为此时尾部可能也在刷新)
UIEdgeInsets insert =self.superScrollView.contentInset;
[UIViewanimateWithDuration:0.2 animations:^{
[self.superScrollViewsetContentInset:UIEdgeInsetsMake(K_HEADER_MAXOFFY,0, insert.bottom,0)];
}];
if (!_activityView)
{
//添加活动指示器
_activityView = [[UIActivityIndicatorViewalloc]initWithFrame:_imageView.frame];
_activityView.activityIndicatorViewStyle =UIActivityIndicatorViewStyleGray;
_activityView.hidesWhenStopped =YES;
[selfaddSubview:_activityView];
}
[_activityViewstartAnimating];
}
}
头部刷新的代码已基本完成了,接下来就是尾部刷新了,代码基本相同的,不过尾部刷新还用添加另外一个监听,也就是父视图(表格的高度),因为表格的高度随时可能发生变化,此时我们要根据表格的高度来更新尾部视图的位置。
同基类一样,在CHEFooterRefreshView将要显示在父视图时添加KVO
//将要显示在父视图上时调用
-(void)willMoveToSuperview:(UIView *)newSuperview
{
//此处切记得调用父类的方法,因为在父类的方法中添加了另一个KVO监听
[superwillMoveToSuperview:newSuperview];
//添加尾部加载时要监听父视图的大小(高度)变化
//监听父视图大小变化
[newSuperview addObserver:selfforKeyPath:@"contentSize"options:NSKeyValueObservingOptionNewcontext:nil];
}
KVO的代理方法
-(void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
{
//此处要记得调用父类的KVO代理方法,不然之前写的代码就没用了
[superobserveValueForKeyPath:keyPathofObject:objectchange:changecontext:context];
self.superScrollViewContentSize =self.superScrollView.contentSize;
//更新自己的位置,让自己始终显示在表格的最下面
[selfupdateFrame];
}
-(void)updateFrame
{
self.frame =CGRectMake(0,self.superScrollViewContentSize.height,self.bounds.size.width,K_FOOTER_REFRESHVIEW_WH);
}
相信这个应该好理解,下面就是同头部刷新视图一样的了,先添加上控件
@implementation CHEFooterRefreshView
{
UILabel *_label; //提示标题
UIImageView *_imageView; //上拉箭头图标
UIActivityIndicatorView *_activityView; //刷新活动指示器
}
初始化它们,好麻烦,不解释了
_label = [[UILabelalloc]initWithFrame:CGRectMake(0,K_BORDER,self.bounds.size.width,K_TITLE_WH)];
_label.backgroundColor = [UIColorclearColor];
_label.textAlignment =NSTextAlignmentCenter;
_label.font = [UIFontsystemFontOfSize:16];
_label.textColor = [UIColorblackColor];
_label.text =K_FOOT_NORMAL_TITLE;
[selfaddSubview:_label];
CGSize size = [K_FOOT_NORMAL_TITLEsizeWithFont:[UIFontsystemFontOfSize:16]constrainedToSize:CGSizeMake(1000,K_TITLE_WH)];
CGFloat oriX =self.bounds.size.width /2 - size.width /2 -K_TITLE_WH *1.2;
_imageView = [[UIImageViewalloc]initWithFrame:CGRectMake(oriX,K_BORDER -2.5,K_TITLE_WH *1.2,K_TITLE_WH *1.2)];
_imageView.image = [UIImageimageNamed:@"arrow_icon.png"];
_imageView.contentMode =UIViewContentModeScaleAspectFit;
_imageView.transform =CGAffineTransformMakeRotation(M_PI);
[selfaddSubview:_imageView];
-(void)setFooterRefreshState:(REFRESHSTATE)footerRefreshState
{
//记得要先调用父类的方法,保存刷新状态
[supersetFooterRefreshState:footerRefreshState];
if (footerRefreshState ==STATE_NORMAL)
{
_label.text =K_FOOT_NORMAL_TITLE;
_imageView.hidden =NO;
[_activityViewstopAnimating];
[UIViewanimateWithDuration:0.2animations:^{
_imageView.transform =CGAffineTransformMakeRotation(M_PI);
}];
}
elseif (footerRefreshState ==STATE_DIDPREPARETOREFRESH)
{
_label.text =K_FOOT_PREPARE_TITLE;
_imageView.hidden =NO;
[_activityViewstopAnimating];
[UIViewanimateWithDuration:0.2animations:^{
_imageView.transform =CGAffineTransformIdentity;
}];
}
elseif (footerRefreshState ==STATE_STARTREFRESH)
{
_label.text =K_FOOT_START_TITLE;
_imageView.hidden =YES;
//传递开始加载消息
objc_msgSend(self.selecterTarget,self.refreshSelecter);
//此时要记录下拉刷新时顶部是否有增加的高度
UIEdgeInsets insert =self.superScrollView.contentInset;
[UIViewanimateWithDuration:0.3 animations:^{
[self.superScrollViewsetContentInset:UIEdgeInsetsMake(insert.top,0,K_FOOTER_MAXOFFY,0)];
}];
if (!_activityView)
{
_activityView = [[UIActivityIndicatorViewalloc]initWithFrame:_imageView.frame];
_activityView.activityIndicatorViewStyle =UIActivityIndicatorViewStyleGray;
_activityView.hidesWhenStopped =YES;
[selfaddSubview:_activityView];
}
[_activityViewstartAnimating];
}
}
代码同头部刷新一样,看上面的解释就行了。到此框架代码已基本完成了,现在你就可以去测试了哦,不过测试时发现根本停不下来啊,等等,我们还忘了加上开始刷新和结束刷新的方法
现在分类里定义一下
-(void)stopHeaderRefresh;
-(void)stopFooterRefresh;
-(void)startHeaderRefresh;
-(void)startFooterRefresh;
这几个是给使用框架着调用的,要实现开始,停止还得在CHEHeaderRefreshView和CHEFooterRefreshView中去实现
于是我们还得在CHEBaseRefreshView中声明一下
-(void)startRefresh; //开始刷新,由子类去实现
-(void)stopRefresh; //结束刷新,由子类去实现
CHEHeaderRefreshView头部刷新实现
//开始刷新
-(void)startRefresh
{
[self.superScrollViewsetContentOffset:CGPointMake(0, -K_HEADER_MAXOFFY)animated:YES];
self.headerRefreshState =STATE_STARTREFRESH;
}
//结束刷新
-(void)stopRefresh
{
//此时要记录上拉加载时底部是否有增加的高度
UIEdgeInsets insert =self.superScrollView.contentInset;
[UIViewanimateWithDuration:0.3animations:^{
[self.superScrollViewsetContentInset:UIEdgeInsetsMake(0,0, insert.bottom,0)];
}];
self.headerRefreshState =STATE_NORMAL;
}
CHEFooterRefreshView尾部刷新实现
//开始刷新
-(void)startRefresh
{
[self.superScrollView setContentOffset:CGPointMake(0,
self.superScrollViewContentSize.height +
K_HEADER_MAXOFFY ) animated : YES ];self.footerRefreshState =STATE_STARTREFRESH;
}
//结束刷新
-(void)stopRefresh
{
//此时要记录下拉刷新时顶部是否有增加的高度
UIEdgeInsets insert =self.superScrollView.contentInset;
[UIViewanimateWithDuration:0.3animations:^{
[self.superScrollViewsetContentInset:UIEdgeInsetsMake(insert.top,0,0,0)];
}];
self.footerRefreshState =STATE_NORMAL;
}
实现自动刷新,以及停止刷新的代码已经写好了,下面该直接在分类的方法里调用了。
分类中的接口实现
-(void)stopHeaderRefresh
{
[头部视图 stopRefresh];
}
-(void)stopFooterRefresh
{
[尾部视图 stopRefresh];
}
-(void)startHeaderRefresh
{
[头部视图 startRefresh];
}
-(void)startFooterRefresh
{
[尾部视图 startRefresh];
}
写到这里发现问题来了,在分类中的开始,结束刷新的方法实现里需要拿到头部刷新视图和尾部刷新视图对象,而在分类中又无法添加属性,我们无法将头部视图和尾部视图作为属性来获取。MJRefresh框架提供了一个解决方案,这里要用到关联对象技术,就是将头,尾部刷新视图和自己通过key绑定起来,然后添加set和get方法,达到类似属性的功能
在分类中添加一下代码
@interface UIScrollView()
@property (strong,nonatomic)CHEHeaderRefreshView *header;
@property (strong,nonatomic)CHEFooterRefreshView *footer;
@end
-(void)setHeader:(CHEHeaderRefreshView *)header
{
//通过键值"header"将自己的header对象绑定起来
objc_setAssociatedObject(self,"header", header,OBJC_ASSOCIATION_ASSIGN);
}
-(void)setFooter:(CHEFooterRefreshView *)footer
{
objc_setAssociatedObject(self,"footer", footer,OBJC_ASSOCIATION_ASSIGN);
}
-(CHEHeaderRefreshView*)header
{
//通过键值"header"读取之前绑定的对象
returnobjc_getAssociatedObject(self,"header");
}
-(CHEFooterRefreshView*)footer
{
returnobjc_getAssociatedObject(self,"footer");
}
写好了set和get方法后,UIScrollView就好比多了一个header和footer "属性",这样我们就可以在原来创建头部视图和尾部视图时给这两个属性赋值了,一下是添加刷新功能的完整代码
-(void)addRefreshWithTarget:(id)target headerSelect:(SEL)headerSelect footerSelect:(SEL)footerSelect
{
if (headerSelect)
{
//添加头部刷新
CHEHeaderRefreshView *header = [[CHEHeaderRefreshViewalloc]initWithFrame:CGRectMake(0, -K_HEADER_REFRESHVIEW_WH,self.bounds.size.width,K_HEADER_REFRESHVIEW_WH)];
header.refreshSelecter = headerSelect;
header.selecterTarget = target;
header.superScrollView =self;
header.scale =1.0;
[selfaddSubview:header];
//此处添加了"属性"赋值
self.header = header;
}
if (footerSelect)
{
//添加尾部刷新
CHEFooterRefreshView *footer = [[CHEFooterRefreshViewalloc]initWithFrame:CGRectMake(0,self.contentSize.height,self.bounds.size.width,K_FOOTER_REFRESHVIEW_WH)];
footer.refreshSelecter = footerSelect;
footer.selecterTarget = target;
footer.superScrollView =self;
footer.scale =1.0;
[selfaddSubview:footer];
//此处添加了"属性"赋值
self.footer = footer;
}
}
有了这两个 "属性" 后就好说了,刚才的开始,停止刷新接口就可以完美实现了
//停止头部刷新
-(void)stopHeaderRefresh
{
[self.headerstopRefresh];
}
//停止尾部刷新
-(void)stopFooterRefresh
{
[self.footerstopRefresh];
}
//开始头部刷新
-(void)startHeaderRefresh
{
[self.headerstartRefresh];
}
//开始尾部刷新
-(void)startFooterRefresh
{
[self.footerstartRefresh];
}
-----------------------------------------------------------------------------------------------------------------------------------------------------
到此,代码已全部完成了,试一下自己的框架吧!