写在前面
上一篇,我们利用Xtrace详细地分析了MJRefresh在UIView生命周期的基础上,做了哪些自定义修改。
本篇,将继续分析其最重要的部分,动态变化。
一、下拉刷新的实现原理
这部分,本想在第一篇介绍,但发现实现原理跟动态实现这篇联系比较紧密,所以还是放在这里写吧。
(一)初始状态
TableView基本布局 运行图通过上两张图,想必大家看出来了,MJRefresh的初始状态下的布局,就是很简单的在UITableView可视View的上部附加了一个视图。这样当我们下拉的时候,这个部分的视图就会显示出来。
(二)“松手刷新”状态
松手刷新这部分的实现原理也很简单,通过监听TableView的origin,当其超过一定数值的时候,就对视图中的组件(这里是Arrow.png 和 Label)做动画。
当然光判断orgin还是不行的,像上图那种情况,用户可能会放弃刷新,所以还需要判断Pan手势的状态,这部分下文我们再详谈。
(三)刷新状态
刷新状态这个状态涉及到的主要部分是TableView.contentInset属性,通过修改ContentInset变相修改origin,实现Subviews的整体下移。
刷新结束之后,再将ContentInset复位。
对这部分不熟悉的童鞋,可以参考我之前的文章。
二、MJRefresh的实现方式
科学是共享的,技术可不是共享的。
好比汽车发动机,大家都知道原理很简单,但是为什么中国制造到现在还是比不过国外?很简单,科学原理就是那样,但是技术,那是西方列强一百多年的经验沉淀,而且对中国实施技术封锁,比不上人家也是必然的。
一不小心跑题了…………
下拉刷新的原理不难,而且也有很多第三方库的封装,MJRefresh目前应该还是Star最多的组件,好在这是开源的,可以让我们一睹芳容。
(一)初始化
MJRefresh在初始化这部分的代码的时候,只用到了KVO,监听了ContentSIze、ContentInset和PanGesture三个属性,具体代码如下:
- (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];
}
}
子类通过覆盖DidChange方法,实现自定义部分。
(二)运行时总体流程
函数调用很平常的一个KVO过程:
- 通知MJRefresh,你监听的属性变化了
- MJRefresh接过通知
- 处理通知
(三)实现细节
这部分就是MJRefresh的核心部分了,其中有的部分,我也没有弄得太明白,毕竟MJRefresh迭代了这么多版本,中间修复了很多BUG,这些BUG场景我可能都没见过,所以不涉及到核心逻辑部分的代码,我就不细说了,免得理解错了。
强势插入
为了更好地理解,最好先回顾一下在你简单拖动的时候,到底发生了什么:
其中SubView的SuperView为TableView
subView.actualY = subView.frame.y - tableView.origin.y //公式一
tableView.origin.y = tableView.original.origin.y(初始值) - panGesture.location.y(touch坐标) // 公式二
公式二代入公式一中,可以得出:
subView.actualY = tableView.subView.frame.y - tableView.original.origin.y + panGesture.location.y
其中subView.frame 与 tableView.original.origin.y 皆为常数,也就是说
subView.actualY = panGesture.location.y + const
即Subviews的实际位置,是与手指位置正相关的:
当你手指向下运动,即下拉时,gesture.location.y 在增大,subViews的实际位置就会下移;
当你手指向上运动,即上滑时,gesture.location.y 在减小,subViews的实际位置就会上移。
3.0 MJRefresh的核心处理代码,在MJRefreshHeader文件中,其中主要是两个方法:
- scrollViewContentOffsetDidChange
- setState
MJ本人正在办教育,所以代码部分自己也加了不少注释,我只是在他的基础上,增加了一些方便理解的注释。
先介绍setState,是因为offsetDidChange方法中会直接对state属性进行设置,也就是说,offsetDidChange方法的实现依赖于state。
3.1 setState
先上源代码:
- (void)setState:(MJRefreshState)state
{
//第一步,判断状态是否有改变,没有改变则直接返回
MJRefreshState oldState = self.state;
if (state == oldState) return;
[super setState:state];
//第二步,根据状态做事情
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];
}];
});
}
}
setState流程图
整个基类方法中,并不对SubView进行设置,主要是为了设置ContentInset,具体SubView的动画则由子类去实现。
大体可以分为两步:
1.主要确定是从什么样的state转换成现在的state的,即:what's oldstate → new state
2.根据不同的切换方式,设置特定的ContentInset的值:
- Idle→Pulling,基类不做处理,子类自定义实现
- 从Pulling→Refreshing,设定ContentInset,使RefreshView能够悬停
- Refreshing→Pulling,不存在,因为在EndRefreshing方法中,直接切换状态为Idle
- 从Refreshing→Idle,则恢复ContentInset,使TableView回弹到正确的位置,隐藏RefreshView
3.1 scrollViewContentOffsetDidChange:
- (void)scrollViewContentOffsetDidChange:(NSDictionary *)change
{
//调用Component的方法,Component这部分代码什么也没做
[super scrollViewContentOffsetDidChange:change];
// 下面的If代码块貌似是为了解决sectionHeader的悬停问题而存在的,跟我们介绍原理关系不大,可以选择性忽略
if (self.state == MJRefreshStateRefreshing) {
//如果还没有加入View Hierarchy则直接返回
if (self.window == nil) return;
// sectionheader停留解决
//insetT = fmax(-offsetY,contentInset.top)
CGFloat insetT = - self.scrollView.mj_offsetY > _scrollViewOriginalInset.top ? - self.scrollView.mj_offsetY : _scrollViewOriginalInset.top;
//insetT = fmin(insetT, self.mj_h +contentInset.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;
}
}
offsetDidChange流程图
其中,方法beginRefreshing的代码如下:
- (void)beginRefreshing
{
[UIView animateWithDuration:MJRefreshFastAnimationDuration animations:^{
self.alpha = 1.0;
}];
self.pullingPercent = 1.0;
// 只要正在刷新,就完全显示
if (self.window) {
self.state = MJRefreshStateRefreshing;
} else {
// 预防正在刷新中时,调用本方法使得header inset回置失败
if (self.state != MJRefreshStateRefreshing) {
self.state = MJRefreshStateWillRefresh;
// 刷新(预防从另一个控制器回到这个控制器的情况,回来要重新刷新一下)
[self setNeedsDisplay];
}
}
}
由我们自己调用的EndRefreshing方法代码如下:
- (void)endRefreshing
{
self.state = MJRefreshStateIdle;
}
整个方法,忽略掉解决Section悬停问题的部分代码,首先进行判断的就是PanGesture.state,确定用户是否还在拖曳。
- 只有在用户松手并且self.state==Pullingstate的情况下,才会进入刷新状态。
- 其余情况,会根据下拉的位移量与阈值大小的比较结果,在Idle和Pulling状态之间来回切换。
有几个参数需要解释一下:
happenOffsetY
该参数的值是初始化状态下的TableView.origin.y,其主要作用是判断MJRefreshView是否需要显示。
参考上面的公式二
tableView.origin.y = tableView.original.origin.y(初始值) - panGesture.location.y(touch坐标)
即
tableView.contentOffset.y = happenOffsetY - panGesture.location.y
当contentOffsetY > happenOffsetY 时,说明用户正在下滑或者下滑之后还没有返回到初始状态,这时候MJRefreshView是没有显示的,所以直接返回就好了;
当contentOffsetY < happenOffsety 时,情况正好相反,我们就需要开始处理数据了。
normal2pullingOffsetY
- 顾名思义,该参数是判断是否需要从Normal状态转换为Pulling状态的阈值。也就是说,下拉的位移量(abs(nowOffset.y - originnal.offset.y))要不小于此参数的绝对值才能进入Pulling状态。
- 其实际值为MJRefreshView.height + originalContentInset.top,即完全显示MJRefreshView所需要的高度。
比如说:
嵌入NavigationBar的话,其值为:-54 - 64 = -118
三、总结
至此,MJRefresh的实现细节部分,终于分析完了。
从整体上来看,MJRefresh采用了Template模式,即模板模式。
通过基类定义整体的流程和共用方法,由子类去延迟实现特定的方法,这样可以在不破坏整个算法结构的同时,重新定义该算法的某些步骤。
不得不说,MJRefresh在基类中定义的方法实现,都很严谨,确实是一个优秀的第三方库。
所以,一般情况下,直接使用MJRefresh作为刷新控件是一个很好的选择,当我们有自己的需求的时候,可以很方面的继承MJRefreshHeader,实现自定义的RefreshView。
网友评论