美文网首页iOS第三方库分析iOS Developer程序员
使用Xtrace分析MJRefresh技术实现细节(二):动态变

使用Xtrace分析MJRefresh技术实现细节(二):动态变

作者: ZZZEoEv | 来源:发表于2016-08-17 17:00 被阅读675次

写在前面

上一篇,我们利用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过程:

  1. 通知MJRefresh,你监听的属性变化了
  2. MJRefresh接过通知
  3. 处理通知

(三)实现细节

这部分就是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。

相关文章

网友评论

本文标题:使用Xtrace分析MJRefresh技术实现细节(二):动态变

本文链接:https://www.haomeiwen.com/subject/mtdmsttx.html