美文网首页
MJRefresh源码分析

MJRefresh源码分析

作者: CerasusLand | 来源:发表于2017-06-29 10:38 被阅读59次

    MJRefresh是李明杰老师的作品,对于iOS的开发者来说,一定非常熟悉这个简单实用,功能强大的下拉加载,上拉刷新的控件,有很好的可定制性,几乎能满足大部分刷新的需求,非常值得钻研学习。

    这个框架的结构设计非常清晰,非常能够体现面向对象的设计原则,一般我总结为各行其是,基类MJRefreshComponent继承自UIView,并承载着整个框架的基础设置,然后MJRefreshHeader和MJRefreshFooter继承了MJRefreshComponent,扩展了下拉刷新和上拉加载的功能,下面的脑图很好的说明了整个框架的继承关系:

    .

    实现原理:

    MJRefresh的实现原理是扩展了UIScrollView,添加了mj_header和mj_footer两个控件,通过KVO机制,监听了scrollView的contentOffset的变化,人为的将刷新这件事情划分成了五种不同的状态,当状态变化的时候,向外暴露出调用钩子,用户可以利用这些钩子来执行刷新和加载的动作。

    说完了原理,来看一下怎么实现的。

    MJRefreshComponent

    这是所有刷新加载控件的基类,主要做的以下几件事:

    1. 声明控件的所有状态。
    2. 声明控件的回调函数。
    3. 添加监听机制。
    4. 提供了刷新,停止刷新的接口。
    5. 提供子类需要实现的方法。
    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,实现了下面几个功能:

    1. 初始化。
    2. 设置Header的高度。
    3. 重新调整Y值。
    4. 根据contentOffset的变化,来切换状态(默认状态,可以刷新的状态,正在刷新的状态),实现方法是:scrollViewContentOffsetDidChange:。
    5. 在切换状态时,执行相应的操作。实现方法是: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;
        }
    }
    

    做三点说明:

    1. 三种状态:默认状态(MJRefreshStateIdle),可以刷新状态(MJRefreshStatePulling),正在刷新状态(MJRefreshStateRefreshing)。
    2. 两种因素:一个是下拉的距离是否超过临界值,另一个是 手指是否离开屏幕。这是状态切换的触发因素。
    3. 一点注意:可以刷新的状态和正在刷新的状态是不同的。因为在手指还贴在屏幕的时候是不能进行刷新的。所以即使在下拉的距离超过了临界距离(状态栏 + 导航栏 + 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];
                }];
             });
        }
    

    两点说明:

    1. 这里是重写MJRefreshState的set方法,在状态变更成MJRefreshStateIdle和MJRefreshStateRefreshing时候,触发的相应的操作,也是针对开始刷新和结束刷新这两个状态切换点来进行相应的触发。
    2. 结束刷新的时候会记录下当前的系统时间,因为header里面有个磨人的label来显示上次刷新的时间。

    MJRefreshStateHeader

    这个是MJRefreshHeader的子类,实现了两个功能:

    1. 简单布局了子控件stateLabel和lastUpdateTimeLabel。
    2. 根据刷新状态,实现了这两个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,它主要做了两件事:

    1. 在MJRefreshStateHeader上添加arrowView和loadingView两个指示控件。
    2. 改变这两个控件显示的样式。
    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到完整功能的实现来看了一遍这个框架,注意到三个贯穿基类和子类的方法prepareplaceSubviewssetState,分别在各层实现自己这一层的功能。

    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的追踪也很有帮助,非常值得我们参考与借鉴。

    相关文章

      网友评论

          本文标题:MJRefresh源码分析

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