MJRefresh是李明杰老师的作品,对于iOS的开发者来说,一定非常熟悉这个简单实用,功能强大的下拉加载,上拉刷新的控件,有很好的可定制性,几乎能满足大部分刷新的需求,非常值得钻研学习。
这个框架的结构设计非常清晰,非常能够体现面向对象的设计原则,一般我总结为各行其是,基类MJRefreshComponent继承自UIView,并承载着整个框架的基础设置,然后MJRefreshHeader和MJRefreshFooter继承了MJRefreshComponent,扩展了下拉刷新和上拉加载的功能,下面的脑图很好的说明了整个框架的继承关系:
实现原理:
MJRefresh的实现原理是扩展了UIScrollView,添加了mj_header和mj_footer两个控件,通过KVO机制,监听了scrollView的contentOffset的变化,人为的将刷新这件事情划分成了五种不同的状态,当状态变化的时候,向外暴露出调用钩子,用户可以利用这些钩子来执行刷新和加载的动作。
说完了原理,来看一下怎么实现的。
MJRefreshComponent
这是所有刷新加载控件的基类,主要做的以下几件事:
- 声明控件的所有状态。
- 声明控件的回调函数。
- 添加监听机制。
- 提供了刷新,停止刷新的接口。
- 提供子类需要实现的方法。
1.声明控件的所有状态
/** 刷新控件的状态 */
typedef NS_ENUM(NSInteger, MJRefreshState) {
/** 普通闲置状态 */
MJRefreshStateIdle = 1,
/** 松开就可以进行刷新的状态 */
MJRefreshStatePulling,
/** 正在刷新中的状态 */
MJRefreshStateRefreshing,
/** 即将刷新的状态 */
MJRefreshStateWillRefresh,
/** 所有数据加载完毕,没有更多的数据了 */
MJRefreshStateNoMoreData
};
2.各种状态的回调Block
/** 进入刷新状态的回调 */
typedef void (^MJRefreshComponentRefreshingBlock)();
/** 开始刷新后的回调(进入刷新状态后的回调) */
typedef void (^MJRefreshComponentbeginRefreshingCompletionBlock)();
/** 结束刷新后的回调 */
typedef void (^MJRefreshComponentEndRefreshingCompletionBlock)();
3.添加监听
#pragma mark - KVO监听
- (void)addObservers
{
NSKeyValueObservingOptions options = NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld;
[self.scrollView addObserver:self forKeyPath:MJRefreshKeyPathContentOffset options:options context:nil];
[self.scrollView addObserver:self forKeyPath:MJRefreshKeyPathContentSize options:options context:nil];
self.pan = self.scrollView.panGestureRecognizer;
[self.pan addObserver:self forKeyPath:MJRefreshKeyPathPanState options:options context:nil];
}
处理监听:
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
{
// 遇到这些情况就直接返回
if (!self.userInteractionEnabled) return;
// 这个就算看不见也需要处理
if ([keyPath isEqualToString:MJRefreshKeyPathContentSize]) {
[self scrollViewContentSizeDidChange:change];
}
// 看不见
if (self.hidden) return;
if ([keyPath isEqualToString:MJRefreshKeyPathContentOffset]) {
[self scrollViewContentOffsetDidChange:change];
} else if ([keyPath isEqualToString:MJRefreshKeyPathPanState]) {
[self scrollViewPanStateDidChange:change];
}
}
4.刷新,停止刷新的接口
#pragma mark - 刷新状态控制
/** 进入刷新状态 */
- (void)beginRefreshing;
- (void)beginRefreshingWithCompletionBlock:(void (^)())completionBlock;
/** 结束刷新状态 */
- (void)endRefreshing;
- (void)endRefreshingWithCompletionBlock:(void (^)())completionBlock;
/** 是否正在刷新 */
- (BOOL)isRefreshing;
5.提供子类需要实现的方法
#pragma mark - 交给子类们去实现
/** 初始化 */
- (void)prepare NS_REQUIRES_SUPER;
/** 摆放子控件frame */
- (void)placeSubviews NS_REQUIRES_SUPER;
/** 当scrollView的contentOffset发生改变的时候调用 */
- (void)scrollViewContentOffsetDidChange:(NSDictionary *)change NS_REQUIRES_SUPER;
/** 当scrollView的contentSize发生改变的时候调用 */
- (void)scrollViewContentSizeDidChange:(NSDictionary *)change NS_REQUIRES_SUPER;
/** 当scrollView的拖拽状态发生改变的时候调用 */
- (void)scrollViewPanStateDidChange:(NSDictionary *)change NS_REQUIRES_SUPER
这个基类其实能够表明刷新加载的实现原理,下面的子类在这个基础上进行了各种情形下的扩展,下面沿着MJRfreshHeader这个分支向下面展开:
MJRefreshHeader
MJRefreshHeader继承自MJRefreshComponent,实现了下面几个功能:
- 初始化。
- 设置Header的高度。
- 重新调整Y值。
- 根据contentOffset的变化,来切换状态(默认状态,可以刷新的状态,正在刷新的状态),实现方法是:scrollViewContentOffsetDidChange:。
- 在切换状态时,执行相应的操作。实现方法是:setState:。
1.初始化方法
提供了两个便利初始化方法,通过refreshingBlock来初始化,通过target-action来初始化:
+ (instancetype)headerWithRefreshingBlock:(MJRefreshComponentRefreshingBlock)refreshingBlock
{
MJRefreshHeader *cmp = [[self alloc] init];
//传入block
cmp.refreshingBlock = refreshingBlock;
return cmp;
}
+ (instancetype)headerWithRefreshingTarget:(id)target refreshingAction:(SEL)action
{
MJRefreshHeader *cmp = [[self alloc] init];
//设置self.refreshingTarget 和 self.refreshingAction
[cmp setRefreshingTarget:target refreshingAction:action];
return cmp;
}
2.设置header的高度
重写prepare方法,来设置header的高度:
- (void)prepare
{
[super prepare];
// 设置用于在NSUserDefaults里存储时间的key
self.lastUpdatedTimeKey = MJRefreshHeaderLastUpdatedTimeKey;
// 设置header的高度
self.mj_h = MJRefreshHeaderHeight;
}
3.重新调整y值
重写placeSubviews方法来调整y值:
- (void)placeSubviews
{
[super placeSubviews];
// 设置y值(当自己的高度发生改变了,肯定要重新调整Y值,所以放到placeSubviews方法中设置y值)
self.mj_y = - self.mj_h - self.ignoredScrollViewContentInsetTop;
//self.ignoredScrollViewContentInsetTop 如果是10,那么就向上移动10
}
4.状态切换
- (void)scrollViewContentOffsetDidChange:(NSDictionary *)change
{
[super scrollViewContentOffsetDidChange:change];
// 在刷新的refreshing状态
if (self.state == MJRefreshStateRefreshing) {
if (self.window == nil) return;
// sectionheader停留解决
CGFloat insetT = - self.scrollView.mj_offsetY > _scrollViewOriginalInset.top ? - self.scrollView.mj_offsetY : _scrollViewOriginalInset.top;
insetT = insetT > self.mj_h + _scrollViewOriginalInset.top ? self.mj_h + _scrollViewOriginalInset.top : insetT;
self.scrollView.mj_insetT = insetT;
self.insetTDelta = _scrollViewOriginalInset.top - insetT;
return;
}
// 跳转到下一个控制器时,contentInset可能会变
_scrollViewOriginalInset = self.scrollView.contentInset;
// 当前的contentOffset
CGFloat offsetY = self.scrollView.mj_offsetY;
// 头部控件刚好出现的offsetY
CGFloat happenOffsetY = - self.scrollViewOriginalInset.top;
// 如果是向上滚动到看不见头部控件,直接返回
// >= -> >
if (offsetY > happenOffsetY) return;
// 普通 和 即将刷新 的临界点
CGFloat normal2pullingOffsetY = happenOffsetY - self.mj_h;
CGFloat pullingPercent = (happenOffsetY - offsetY) / self.mj_h;
if (self.scrollView.isDragging) { // 如果正在拖拽
self.pullingPercent = pullingPercent;
if (self.state == MJRefreshStateIdle && offsetY < normal2pullingOffsetY) {
// 转为即将刷新状态
self.state = MJRefreshStatePulling;
} else if (self.state == MJRefreshStatePulling && offsetY >= normal2pullingOffsetY) {
// 转为普通状态
self.state = MJRefreshStateIdle;
}
} else if (self.state == MJRefreshStatePulling) {// 即将刷新 && 手松开
// 开始刷新
[self beginRefreshing];
} else if (pullingPercent < 1) {
self.pullingPercent = pullingPercent;
}
}
做三点说明:
- 三种状态:默认状态(MJRefreshStateIdle),可以刷新状态(MJRefreshStatePulling),正在刷新状态(MJRefreshStateRefreshing)。
- 两种因素:一个是下拉的距离是否超过临界值,另一个是 手指是否离开屏幕。这是状态切换的触发因素。
- 一点注意:可以刷新的状态和正在刷新的状态是不同的。因为在手指还贴在屏幕的时候是不能进行刷新的。所以即使在下拉的距离超过了临界距离(状态栏 + 导航栏 + header高度),如果手指没有离开屏幕,那么也不能马上进行刷新,而是将状态切换为:可以刷新。一旦手指离开了屏幕,马上将状态切换为正在刷新。
5.状态切换的相应操作
- (void)setState:(MJRefreshState)state
{
MJRefreshCheckState
// 根据状态做事情
if (state == MJRefreshStateIdle) {
if (oldState != MJRefreshStateRefreshing) return;
// 保存刷新时间
[[NSUserDefaults standardUserDefaults] setObject:[NSDate date] forKey:self.lastUpdatedTimeKey];
[[NSUserDefaults standardUserDefaults] synchronize];
// 恢复inset和offset
[UIView animateWithDuration:MJRefreshSlowAnimationDuration animations:^{
self.scrollView.mj_insetT += self.insetTDelta;
// 自动调整透明度
if (self.isAutomaticallyChangeAlpha) self.alpha = 0.0;
} completion:^(BOOL finished) {
self.pullingPercent = 0.0;
if (self.endRefreshingCompletionBlock) {
self.endRefreshingCompletionBlock();
}
}];
} else if (state == MJRefreshStateRefreshing) {
dispatch_async(dispatch_get_main_queue(), ^{
[UIView animateWithDuration:MJRefreshFastAnimationDuration animations:^{
CGFloat top = self.scrollViewOriginalInset.top + self.mj_h;
// 增加滚动区域top
self.scrollView.mj_insetT = top;
// 设置滚动位置
[self.scrollView setContentOffset:CGPointMake(0, -top) animated:NO];
} completion:^(BOOL finished) {
[self executeRefreshingCallback];
}];
});
}
两点说明:
- 这里是重写MJRefreshState的set方法,在状态变更成MJRefreshStateIdle和MJRefreshStateRefreshing时候,触发的相应的操作,也是针对开始刷新和结束刷新这两个状态切换点来进行相应的触发。
- 结束刷新的时候会记录下当前的系统时间,因为header里面有个磨人的label来显示上次刷新的时间。
MJRefreshStateHeader
这个是MJRefreshHeader的子类,实现了两个功能:
- 简单布局了子控件stateLabel和lastUpdateTimeLabel。
- 根据刷新状态,实现了这两个label的显示状态的切换。
1.布局子控件
重写prepare方法,初始化间距和要显示的文字
// 初始化间距
self.labelLeftInset = MJRefreshLabelLeftInset;
// 初始化文字
[self setTitle:[NSBundle mj_localizedStringForKey:MJRefreshHeaderIdleText] forState:MJRefreshStateIdle];
[self setTitle:[NSBundle mj_localizedStringForKey:MJRefreshHeaderPullingText] forState:MJRefreshStatePulling];
[self setTitle:[NSBundle mj_localizedStringForKey:MJRefreshHeaderRefreshingText] forState:MJRefreshStateRefreshing];
这个方法供调用来自定义设置不同状态下的显示文本。
#pragma mark - 公共方法
- (void)setTitle:(NSString *)title forState:(MJRefreshState)state
以刷新状态为Key,以显示的title为值,来储存不同状态的文本信息。
placeSubviews方法,负责对子控件进行布局,如果更新时间label是隐藏的,则让状态label撑满整个header,如果更新时间label不是隐藏的,根据约束设置更新时间label和状态label(高度各占一半)。
重写setState方法,根据传入的state不同,在stateLabel和lastUpdateTimeLabel里切换相应的文字,stateLabel里的文字直接从stateTitles字典里取出,lastUpdateTimeLabel里的文字需要通过一个方法来取出。
- (void)setState:(MJRefreshState)state
{
MJRefreshCheckState
// 设置状态文字
self.stateLabel.text = self.stateTitles[@(state)];
// 重新设置key(重新显示时间)
self.lastUpdatedTimeKey = self.lastUpdatedTimeKey;
}
MJRefreshNormalHeader
MJRefreshNormalHeader 继承于 MJRefreshStateHeader,它主要做了两件事:
- 在MJRefreshStateHeader上添加arrowView和loadingView两个指示控件。
- 改变这两个控件显示的样式。
1.重写prepare方法
给indicatorView定义一个初始样式。
self.activityIndicatorViewStyle = UIActivityIndicatorViewStyleGray;
2.重写placeSubviews方法
要注意的点是,因为stateLabel和lastUpdatedTimeLabel是上下并排分布的,而arrowView或loadingView是在这二者的左边,所以为了避免这两组重合,在计算arrowView或loadingView的center的时候,需要获取stateLabel和lastUpdatedTimeLabel两个控件的宽度并比较大小,将较大的一个作为两个label的‘最宽距离’,再计算center,这样一来就不会重合了。
作者对于文本宽度计算的封装,也可以用在自己的项目中:
- (CGFloat)mj_textWith {
CGFloat stringWidth = 0;
CGSize size = CGSizeMake(MAXFLOAT, MAXFLOAT);
if (self.text.length > 0) {
#if defined(__IPHONE_OS_VERSION_MAX_ALLOWED) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 70000
stringWidth =[self.text
boundingRectWithSize:size
options:NSStringDrawingUsesLineFragmentOrigin
attributes:@{NSFontAttributeName:self.font}
context:nil].size.width;
#else
stringWidth = [self.text sizeWithFont:self.font
constrainedToSize:size
lineBreakMode:NSLineBreakByCharWrapping].width;
#endif
}
return stringWidth;
}
主要是不同版本的api有变化。
setState方法
根据不同的状态修改箭头的transfrom属性,控制indicatorView的显示样式和是否要显示。
- (void)setState:(MJRefreshState)state
{
MJRefreshCheckState
// 根据状态更新arrowView和loadingView的显示
if (state == MJRefreshStateIdle) {
//1. 设置为默认状态
if (oldState == MJRefreshStateRefreshing) {
//1.1 从正在刷新状态中切换过来
self.arrowView.transform = CGAffineTransformIdentity;
[UIView animateWithDuration:MJRefreshSlowAnimationDuration animations:^{
//隐藏菊花
self.loadingView.alpha = 0.0;
} completion:^(BOOL finished) {
// 如果执行完动画发现不是idle状态,就直接返回,进入其他状态
if (self.state != MJRefreshStateIdle) return;
//菊花停止旋转
self.loadingView.alpha = 1.0;
[self.loadingView stopAnimating];
//显示箭头
self.arrowView.hidden = NO;
}];
} else {
//1.2 从其他状态中切换过来
[self.loadingView stopAnimating];
//显示箭头并设置为初始状态
self.arrowView.hidden = NO;
[UIView animateWithDuration:MJRefreshFastAnimationDuration animations:^{
self.arrowView.transform = CGAffineTransformIdentity;
}];
}
} else if (state == MJRefreshStatePulling) {
//2. 设置为可以刷新状态
[self.loadingView stopAnimating];
self.arrowView.hidden = NO;
[UIView animateWithDuration:MJRefreshFastAnimationDuration animations:^{
//箭头倒立
self.arrowView.transform = CGAffineTransformMakeRotation(0.000001 - M_PI);
}];
} else if (state == MJRefreshStateRefreshing) {
//3. 设置为正在刷新状态
self.loadingView.alpha = 1.0; // 防止refreshing -> idle的动画完毕动作没有被执行
//菊花旋转
[self.loadingView startAnimating];
//隐藏arrowView
self.arrowView.hidden = YES;
}
}
这样我们就从上到下,从基础api到完整功能的实现来看了一遍这个框架,注意到三个贯穿基类和子类的方法prepare,placeSubviews,setState,分别在各层实现自己这一层的功能。
MJRefreshHeader:负责header的高度设置和调整header在scrollView中的位置。
MJRefreshStateHeader:负责header内部stateLabel和lastUpdateTimeLabel的布局和不同状态下内部文字的显示。
MJRefreshNormalHeader:负责header内部子控件loadingView和arrowView的布局和不同状态下的显示。
这样就非常好的实现了软件架构思维中的分层解耦,各层互不影响,如果某一天我们要给MJRefresh添加一个新的样式,我们就只需要在一层上面做文章,而不用牵一发而动全身。例如框架里面提供的MJRefreshGifHeader和MJRefreshNormalHeader处于同一层,二者具有相同的stateLabel和lastUpdateTimeLabel,这是左侧的显示状态不同:
- MJRefreshNormalHeader左侧是箭头和indicatorView;
- MJRefreshGifHeader左侧是一个gif动画。
来看一下是怎么实现的:
MJRefreshGifHeader的左侧是一个imageView,并提供了两个设置图片数组对的接口:
/** 设置state状态下的动画图片images 动画持续时间duration*/
- (void)setImages:(NSArray *)images duration:(NSTimeInterval)duration forState:(MJRefreshState)state;
- (void)setImages:(NSArray *)images forState:(MJRefreshState)state;
然而MJRefreshGifHeader只需要和MJRefreshNormalHeader一样,重写基类提供的三个方法来实现显示gif图片的功能。
1. 初始化和右侧label的间距
- (void)prepare
{
[super prepare];
// 初始化间距
self.labelLeftInset = 20;
}
2.设置承载gif的imageView的位置
- (void)placeSubviews
{
[super placeSubviews];
//如果约束存在,就立即返回
if (self.gifView.constraints.count) return;
self.gifView.frame = self.bounds;
if (self.stateLabel.hidden && self.lastUpdatedTimeLabel.hidden) {
//如果stateLabel和lastUpdatedTimeLabel都在隐藏状态,将gif剧中显示
self.gifView.contentMode = UIViewContentModeCenter;
} else {
//如果stateLabel和lastUpdatedTimeLabel中至少一个存在,则根据label的宽度设置gif的位置
self.gifView.contentMode = UIViewContentModeRight;
CGFloat stateWidth = self.stateLabel.mj_textWith;
CGFloat timeWidth = 0.0;
if (!self.lastUpdatedTimeLabel.hidden) {
timeWidth = self.lastUpdatedTimeLabel.mj_textWith;
}
CGFloat textWidth = MAX(stateWidth, timeWidth);
self.gifView.mj_w = self.mj_w * 0.5 - textWidth * 0.5 - self.labelLeftInset;
}
}
3.设置图片数组动画
- (void)setState:(MJRefreshState)state
{
MJRefreshCheckState
if (state == MJRefreshStatePulling || state == MJRefreshStateRefreshing) {
//1. 如果传进来的状态是可以刷新和正在刷新
NSArray *images = self.stateImages[@(state)];
if (images.count == 0) return;
[self.gifView stopAnimating];
if (images.count == 1) {
//1.1 单张图片
self.gifView.image = [images lastObject];
} else {
//1.2 多张图片
self.gifView.animationImages = images;
self.gifView.animationDuration = [self.stateDurations[@(state)] doubleValue];
[self.gifView startAnimating];
}
} else if (state == MJRefreshStateIdle) {
//2.如果传进来的状态是默认状态
[self.gifView stopAnimating];
}
}
总结
这样就沿着一条线把MJRefresh的实现思路和方法解读了一遍,总之这个框架的设计非常优美,通过一个基类来定义一些状态和一些需要子类实现的接口。通过一层一层地继承,让每一层的子类各司其职,只完成真正属于自己的任务,提高了框架的可定制性,而且对于功能的扩展和bug的追踪也很有帮助,非常值得我们参考与借鉴。