美文网首页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