MJRefresh 使用及源码分析
开源库信息:
MJRefresh是目前用得比较多的下拉刷新,上拉加载开源库了,它支持UIScrollView、UITableView、UICollectionView、UIWebView
整个类结构图如下:
下面是在网上找的比较好的一张图,也附带给大家
在开始讲解MJRefresh源码之前大家最好对UIScrollView的各个尺寸数据有个明确的认识,下面是在网上找的一个比较好的图大家可以对照着这个来看:
MJRefreshComponent
MJRefreshComponent是所有上拉下拉控件的基类,它主要是通过KVO实现对scrollView contentOffset,contentSize,以及scrollView 手势状态也就是: self.scrollView.panGestureRecognizer state的监听,来分别触发下面的对应方法,这些方法在不同的子类都有自己的实现,这个后面会具体展开介绍:
// scrollView ContentOffset改变的时候触发 |
MJRefreshHeader
MJRefreshHeader主要负责通过ContentOffset的变化以及是否正在滑动状态来确定上拉,下滑控件的状态。并负责上一次刷新时间的记录。
MJRefreshStateHeader
MJRefreshStateHeader 负责状态文本以及上一次更新时间文本的显示。
MJRefreshNormalHeader
MJRefreshNormalHeader 负责箭头和加载菊花的状态显示。
MJRefreshGifHeader
MJRefreshGifHeader 负责加载Gif图片的状态控制
MJRefreshFooter
MJRefreshFooter 主要负责上拉控件状态的维护
MJRefreshAutoFooter
MJRefreshAutoFooter 负责维护ContentOffset 与 state的对应关系。
MJRefreshAutoStateFooter
MJRefreshAutoStateFooter 主要负责状态文本的控制。
MJRefreshAutoNormalFooter
MJRefreshAutoNormalFooter 主要负责加载菊花的状态控制
MJRefreshAutoGifFooter
MJRefreshAutoGifFooter 主要负责Gif类型的加载动画控制
MJRefreshBackStateFooter
MJRefreshBackStateFooter 负责自动返回类型上拉控件的状态文本控制。
MJRefreshBackNormalFooter
MJRefreshBackNormalFooter负责自动返回类型上拉控件的加载菊花状态控制。
MJRefreshBackGifFooter
MJRefreshBackGifFooter 负责自动返回类型上拉控件加载Gif动画的播放控制。
MJRefreshConst
MJRefreshConst 里面放置的是整个MJRefresh的常量以及关键宏定义。
UIScrollView+MJExtension
UIScrollView+MJExtension存放的是UIScrollView尺寸位置数据的便捷方法
UIView+MJExtension
UIView+MJExtension 和 UIScrollView+MJExtension类似存放的是UIView尺寸位置数据的便捷方法
UIScrollView+MJRefresh
UIScrollView+MJRefresh 中通过关联属性方式为UIScrollView添加了MJRefreshHeader和MJRefreshFooter
NSBundle+MJRefresh
NSBundle+MJRefresh 是 MJRefresh 内的文本以及图片资源获取方法。
MJRefreshConfig
用于存放MJRefresh的配置的类,目前只存放languageCode,暂时没多大用处。
其实整个大的方向还是遵循
手动滑动ScrollView --> ScrollView ContentOffset / 滑动状态 发生变化 --> MJRefresh 状态发生变化 --> 触发对应的改变,比如上一次更新时间,状态文本,菊花状态等等。 |
下面就顺着这个思路来对MJRefresh源码进行解析:
源码解析
MJRefreshComponent
状态常量的定义
/** 刷新控件的状态 */ |
状态改变的回调类型定义
/** 进入刷新状态的回调 */ |
关键属性
/** 刷新状态 一般交给子类内部实现 */ |
状态触发,及状态关键节点回调
状态触发:
/** 进入刷新状态 */ |
状态关键节点回调:
/** 正在刷新的回调 */ |
供给子类实现的方法
/** 初始化 */ |
我们截取官方Demo例子作为一个场景来对整个代码解析:
__unsafe_unretained UITableView *tableView = self.tableView; |
当然我们会先将注意力聚焦在MJRefreshComponent。
1. MJRefreshComponent初始化
- (instancetype)initWithFrame:(CGRect)frame { |
这部分的工作主要是初始状态的设置。在设置状态的时候会调用state的setter。这里面会触发布局,这也是为什么autoresizingMask一定要在prepare进行设置的原因:
- (void)setState:(MJRefreshState)state{ |
对于MJRefreshComponent这里面,placeSubviews是空的。主要是对子类placeSubviews的触发。
- (void)layoutSubviews { |
所以初始化过程主要完成如下任务:
- 调用prepare做一些准备工作
- 设置MJRefresh的状态为Idle状态
- 触发布局
2. MJRefreshComponent添加到父控件:
新建MJRefreshComponent子类成功后会通过setMj_header添加到UIScrollView或者它的子类上。
- (void)setMj_header:(MJRefreshHeader *)mj_header { |
在调用insertSubview 的时候 MJRefreshComponent的 willMoveToSuperview:
- (void)willMoveToSuperview:(UIView *)newSuperview { |
willMoveToSuperview 中会先移除对旧父控件的关键事件监听,监听新的父UIScollView的关键事件,并记录下UIScrollView最开始的contentInset。
addObservers 方法中主要通过KVO方式让MJRefreshComponent监听父控件scrollView的ContentOffset,ContentSize 以及
scrollView的手势状态。
- (void)addObservers { |
一旦监听的变量发生变化那么就会到observeValueForKeyPath中进行处理:
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context { |
(void)beginRefreshing {
[UIView animateWithDuration:MJRefreshFastAnimationDuration animations:^{
self.alpha = 1.0;
}];
self.pullingPercent = 1.0;
// 只要正在刷新,就完全显示
if (self.window) {
self.state = MJRefreshStateRefreshing;
} else {
//…..
}
}(void)endRefreshing {
MJRefreshDispatchAsyncOnMainQueue(self.state = MJRefreshStateIdle;)
}
****4. 下拉进度的管理:****
和setState一样下拉进度也是受contentOffset影响的,所以下拉进度的设置也是提供给子类调用的。下拉进度的改变会导致下拉控件透明度的同步变化。(void)setPullingPercent:(CGFloat)pullingPercent {
_pullingPercent = pullingPercent;
if (self.isRefreshing) return;
if (self.isAutomaticallyChangeAlpha) {
self.alpha = pullingPercent;
}
}
****MJRefreshStateHeader****
MJRefreshComponent像是一个没有灵魂的父类,它只负责监听scrollView的contentOffset,contentSize,以及触摸状态的变化,将这些变化传递给子类。(void)scrollViewContentOffsetDidChange:(NSDictionary *)change {
[super scrollViewContentOffsetDidChange:change];
// 在刷新的refreshing状态
if (self.state == MJRefreshStateRefreshing) {
[self resetInset];
return;
}
// 跳转到下一个控制器时,contentInset可能会变, 这时候将最初的contentInset保存在_scrollViewOriginalInset
_scrollViewOriginalInset = self.scrollView.mj_inset;// 当前的contentOffset
CGFloat offsetY = self.scrollView.mj_offsetY;
// 头部控件刚好出现的offsetY,这里由于UIScrollView默认是贴着可见的边缘,也就是导航栏的底部。所以可以用- self.scrollViewOriginalInset.top来表示。
CGFloat happenOffsetY = - self.scrollViewOriginalInset.top;// 如果是向上滚动到看不见头部控件,直接返回
// >= -> >
if (offsetY > happenOffsetY) return;// 普通 和 即将刷新 的临界点,normal2pullingOffsetY 表示MJRefreshHeader完全露出来的位置
CGFloat normal2pullingOffsetY = happenOffsetY - self.mj_h;
// 这个是MJRefreshHeader露出来的百分比
CGFloat pullingPercent = (happenOffsetY - offsetY) / self.mj_h;if (self.scrollView.isDragging) { // 如果正在拖拽
self.pullingPercent = pullingPercent;
// 如果当前是空闲状态,并且偏移量超过了完全露出来的距离,但是由于当前正在处于拖拽状态则状态还是MJRefreshStatePulling
if (self.state == MJRefreshStateIdle && offsetY < normal2pullingOffsetY) {
// 转为即将刷新状态
self.state = MJRefreshStatePulling;
//如果当前处于MJRefreshStatePulling状态,偏移量小于完全露出来的距离,那么状态改为MJRefreshStateIdle
} else if (self.state == MJRefreshStatePulling && offsetY >= normal2pullingOffsetY) {
// 转为普通状态
self.state = MJRefreshStateIdle;
}
//如果松收那么就切换到MJRefreshStateRefreshing状态
} else if (self.state == MJRefreshStatePulling) {// 即将刷新 && 手松开
// 开始刷新
[self beginRefreshing];
} else if (pullingPercent < 1) {
self.pullingPercent = pullingPercent;
}
}
|
- 是否处于拖拽状态
- 当前UIScrollView的offset与下拉头完全露出来的位置的关系
如果当前是空闲状态,并且偏移量超过了下拉头部完全露出来的距离,但是由于当前正在处于拖拽状态则状态还是MJRefreshStatePulling,在处于拖拽状态下,如果当前处于MJRefreshStatePulling状态,偏移量小于完全露出来的距离,那么状态改为MJRefreshStateIdle。如果松手那么就切换到MJRefreshStateRefreshing状态。并触发beginRefreshing调用上层业务进行刷新。
下面我们来看下状态的设置:
- (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.endRefreshingAnimateCompletionBlock) {
self.endRefreshingAnimateCompletionBlock();
}
// 自动调整透明度
if (self.isAutomaticallyChangeAlpha) self.alpha = 0.0;
} completion:^(BOOL finished) {
self.pullingPercent = 0.0;
if (self.endRefreshingCompletionBlock) {
self.endRefreshingCompletionBlock();
}
}];
} else if (state == MJRefreshStateRefreshing) {
MJRefreshDispatchAsyncOnMainQueue({
[UIView animateWithDuration:MJRefreshFastAnimationDuration animations:^{
if (self.scrollView.panGestureRecognizer.state != UIGestureRecognizerStateCancelled) {
CGFloat top = self.scrollViewOriginalInset.top + self.mj_h;
// 增加滚动区域top
self.scrollView.mj_insetT = top;
// 设置滚动位置
CGPoint offset = self.scrollView.contentOffset;
offset.y = -top;
[self.scrollView setContentOffset:offset animated:NO];
}
} completion:^(BOOL finished) {
[self executeRefreshingCallback];
}];
})
}
}
|
(void)prepare {
[super prepare];
// 默认底部控件100%出现时才会自动刷新
self.triggerAutomaticallyRefreshPercent = 1.0;
// 设置为默认状态
self.automaticallyRefresh = YES;
// 自动刷新的次数
self.autoTriggerTimes = 1;
}
在prepare方法中设置了触发自动刷新的百分比,以及将触发状态设置为自动刷新状态,autoTriggerTimes被设置为1次。autoTriggerTimes是上拉不松手的情况下触发自动刷新的次数超过这个次数,上拉只有松手的情况下才会触发刷新,如果要自动刷新次数不受限那么将autoTriggerTimes设置为-1即可。
创建好后在我们将MJRefreshAutoFooter添加到UIScrollView上面的时候会调用willMoveToSuperview,这时候ContentInset Bottom会相应地增加mj_h,如果newSuperview = nil 表示从父控件中移除,ContentInset Bottom会减少mj_h。(void)willMoveToSuperview:(UIView *)newSuperview {
[super willMoveToSuperview:newSuperview];
if (newSuperview) { // 新的父控件
if (self.hidden == NO) {
self.scrollView.mj_insetB += self.mj_h;
}
// 设置位置
self.mj_y = _scrollView.mj_contentH;
} else { // 被移除了
if (self.hidden == NO) {
self.scrollView.mj_insetB -= self.mj_h;
}
}
}
mj_h是在MJRefreshFooter prepare方法中设置的。
一旦UIScrollView完成数据的加载就会触发ContentSize发生变化,scrollViewContentSizeDidChange会被调用,这时候MJRefreshAutoFooter的位置就会重新被设置,它会被追加到UIScrollView 内容的最底部,注意不是UIScrollView的最底部。(void)scrollViewContentSizeDidChange:(NSDictionary *)change {
[super scrollViewContentSizeDidChange:change];
// 设置位置
self.mj_y = self.scrollView.mj_contentH + self.ignoredScrollViewContentInsetBottom;
}
在内容超过一个屏幕后,如果我们向上拉页面,拉到footer显示出triggerAutomaticallyRefreshPercent百分比后即使我们不松手的情况下也会自动触发beginRefreshing进行刷新。(void)scrollViewContentOffsetDidChange:(NSDictionary *)change {
[super scrollViewContentOffsetDidChange:change];if (self.state != MJRefreshStateIdle || !self.automaticallyRefresh || self.mj_y == 0) return;
if (_scrollView.mj_insetT + _scrollView.mj_contentH > _scrollView.mj_h) { // 内容超过一个屏幕
// 这里的_scrollView.mj_contentH替换掉self.mj_y更为合理
if (_scrollView.mj_offsetY >= _scrollView.mj_contentH - _scrollView.mj_h + self.mj_h * self.triggerAutomaticallyRefreshPercent + _scrollView.mj_insetB - self.mj_h) {
// 防止手松开时连续调用
CGPoint old = [change[@”old”] CGPointValue];
CGPoint new = [change[@”new”] CGPointValue];
if (new.y <= old.y) return;
if (_scrollView.isDragging) {
self.triggerByDrag = YES;
}
// 当底部刷新控件完全出现时,才刷新
[self beginRefreshing];
}
}
}
|
(void)scrollViewPanStateDidChange:(NSDictionary *)change {
[super scrollViewPanStateDidChange:change];
if (self.state != MJRefreshStateIdle) return;
UIGestureRecognizerState panState = _scrollView.panGestureRecognizer.state;
switch (panState) {
// 手松开
case UIGestureRecognizerStateEnded: {
if (_scrollView.mj_insetT + _scrollView.mj_contentH <= _scrollView.mj_h) { // 不够一个屏幕
if (_scrollView.mj_offsetY >= - _scrollView.mj_insetT) { // 向上拽
self.triggerByDrag = YES;
[self beginRefreshing];
}
} else { // 超出一个屏幕
if (_scrollView.mj_offsetY >= _scrollView.mj_contentH + _scrollView.mj_insetB - _scrollView.mj_h) {
self.triggerByDrag = YES;
[self beginRefreshing];
}
}
} break;
case UIGestureRecognizerStateBegan: {
[self resetTriggerTimes];
} break;
default: break;
}
}
状态设置:(void)setState:(MJRefreshState)state {
MJRefreshCheckStateif (state == MJRefreshStateRefreshing) {
[self executeRefreshingCallback];
} else if (state == MJRefreshStateNoMoreData || state == MJRefreshStateIdle) {
if (self.triggerByDrag) {
if (!self.unlimitedTrigger) {
self.leftTriggerTimes -= 1;
}
self.triggerByDrag = NO;
}
if (MJRefreshStateRefreshing == oldState) {
if (self.scrollView.pagingEnabled) {
CGPoint offset = self.scrollView.contentOffset;
offset.y -= self.scrollView.mj_insetB;
[UIView animateWithDuration:MJRefreshSlowAnimationDuration animations:^{
self.scrollView.contentOffset = offset;
if (self.endRefreshingAnimateCompletionBlock) {
self.endRefreshingAnimateCompletionBlock();
}
} completion:^(BOOL finished) {
if (self.endRefreshingCompletionBlock) {
self.endRefreshingCompletionBlock();
}
}];
return;
}
if (self.endRefreshingCompletionBlock) {
self.endRefreshingCompletionBlock();
}
}
}
}
|
(void)scrollViewContentSizeDidChange:(NSDictionary *)change {
[super scrollViewContentSizeDidChange:change];
// 内容的高度
CGFloat contentHeight = self.scrollView.mj_contentH + self.ignoredScrollViewContentInsetBottom;
// 表格的高度
CGFloat scrollHeight = self.scrollView.mj_h - self.scrollViewOriginalInset.top - self.scrollViewOriginalInset.bottom + self.ignoredScrollViewContentInsetBottom;
// 设置位置和尺寸
self.mj_y = MAX(contentHeight, scrollHeight);
}
在内容高度大于UIScrollView的时候,它会追加在内容最后面,这时候MJRefreshBackFooter也是在UIScrollView的外面,在滑到UIScrollView的时候开始加载。如果UIScrollView的高度大于内容高度,那么它会被加在UIScrollView最底部。
我们接下来看下MJRefreshBackFooter的状态变化:(void)scrollViewContentOffsetDidChange:(NSDictionary *)change {
[super scrollViewContentOffsetDidChange:change];// 如果正在刷新,直接返回
if (self.state == MJRefreshStateRefreshing) return;_scrollViewOriginalInset = self.scrollView.mj_inset;
// 当前的contentOffset
CGFloat currentOffsetY = self.scrollView.mj_offsetY;
// 尾部控件刚好出现的offsetY
CGFloat happenOffsetY = [self happenOffsetY];
// 如果是向下滚动到看不见尾部控件,直接返回
if (currentOffsetY <= happenOffsetY) return;CGFloat pullingPercent = (currentOffsetY - happenOffsetY) / self.mj_h;
// 如果已全部加载,仅设置pullingPercent,然后返回
if (self.state == MJRefreshStateNoMoreData) {
self.pullingPercent = pullingPercent;
return;
}if (self.scrollView.isDragging) {
self.pullingPercent = pullingPercent;
// 普通 和 即将刷新 的临界点
CGFloat normal2pullingOffsetY = happenOffsetY + self.mj_h;
if (self.state == MJRefreshStateIdle && currentOffsetY > normal2pullingOffsetY) {
// 转为即将刷新状态
self.state = MJRefreshStatePulling;
} else if (self.state == MJRefreshStatePulling && currentOffsetY <= normal2pullingOffsetY) {
// 转为普通状态
self.state = MJRefreshStateIdle;
}
} else if (self.state == MJRefreshStatePulling) {// 即将刷新 && 手松开
// 开始刷新
[self beginRefreshing];
} else if (pullingPercent < 1) {
self.pullingPercent = pullingPercent;
}
}
|
- (void)setState:(MJRefreshState)state {
MJRefreshCheckState
// 根据状态来设置属性
if (state == MJRefreshStateNoMoreData || state == MJRefreshStateIdle) {
// 刷新完毕
if (MJRefreshStateRefreshing == oldState) {
[UIView animateWithDuration:MJRefreshSlowAnimationDuration animations:^{
self.scrollView.mj_insetB -= self.lastBottomDelta;
if (self.endRefreshingAnimateCompletionBlock) {
self.endRefreshingAnimateCompletionBlock();
}
// 自动调整透明度
if (self.isAutomaticallyChangeAlpha) self.alpha = 0.0;
} completion:^(BOOL finished) {
self.pullingPercent = 0.0;
if (self.endRefreshingCompletionBlock) {
self.endRefreshingCompletionBlock();
}
}];
}
CGFloat deltaH = [self heightForContentBreakView];
// 刚刷新完毕
if (MJRefreshStateRefreshing == oldState && deltaH > 0 && self.scrollView.mj_totalDataCount != self.lastRefreshCount) {
self.scrollView.mj_offsetY = self.scrollView.mj_offsetY;
}
} else if (state == MJRefreshStateRefreshing) {
// 记录刷新前的数量
self.lastRefreshCount = self.scrollView.mj_totalDataCount;
[UIView animateWithDuration:MJRefreshFastAnimationDuration animations:^{
CGFloat bottom = self.mj_h + self.scrollViewOriginalInset.bottom;
CGFloat deltaH = [self heightForContentBreakView];
if (deltaH < 0) { // 如果内容高度小于view的高度
bottom -= deltaH;
}
self.lastBottomDelta = bottom - self.scrollView.mj_insetB;
self.scrollView.mj_insetB = bottom;
self.scrollView.mj_offsetY = [self happenOffsetY] + self.mj_h;
} completion:^(BOOL finished) {
[self executeRefreshingCallback];
}];
}
}
```
MJRefresh最核心的部分代码已经介绍完毕了,后续的一些,比如包含State的Header以及Footer 都是用于处理与状态文本相关的类,包含Normal的Header以及Footer都是用于处理箭头和菊花状态的类,而包含Gif的Header以及Footer都是用于处理Gif动画的类,这些类都是基于state, pullingPercent 这些关键属性的变化而改变的,所以理解上面介绍的如何根据Content Offset 变化 确定state以及pullingPercent是理解MJRefresh的核心。