Osheep

时光不回头,当下最重要。

解读 MJRefresh 框架

MJRefresh 是 OC 语言里面算是一款比较通用的刷新框架了,可以用于 UITableView 和 UICollectionView 的刷新,从而可以很方便的完成下拉刷新和上拉加载更多。而且可定制程度也很高。

最近需要在公司的项目上加入一个刷新的特效,而公司项目用的刷新框架是 MJefresh ,所以在完成需求的同时就顺便把 MJRefresh 框架的源码给看了一遍。然后写下这篇文章总结一下。

框架总览

打开 MJRefresh 的 github 可以看到里面框架的结构图

《解读 MJRefresh 框架》

mjrefresh结构图.png

其中核心的类我都用红色的框标记出来了。其核心类有:

  • MJRefreshComponent
  • MJRefreshHeader
  • MJRefreshFooter
  • MJRefreshBackFooter
  • MJRefreshAutoFooter

而其他的类都是继承自核心类然后进行其个性化定制的。而本篇文章也是分析核心类,其他的类则可以自行阅读其实也很简单的。接下来从三个方面解读源码 基类 MJRefreshComponent、Header 部分 和 Footer 部分。

本篇文章只是解读框架内部是如何实现的,以及原理是怎么样的。所以会对重要的功能进行解读。而对于其他细节部分就会一笔带过或者不提。

基类 MJRefreshComponent

先看该类的初始化

#pragma mark - 初始化
- (instancetype)initWithFrame:(CGRect)frame
{
    if (self = [super initWithFrame:frame]) {
        // 准备工作
        [self prepare];

        // 默认是普通状态
        self.state = MJRefreshStateIdle;
    }
    return self;
}

- (void)prepare
{
    // 基本属性
    self.autoresizingMask = UIViewAutoresizingFlexibleWidth;
    self.backgroundColor = [UIColor clearColor];
}

- (void)layoutSubviews
{
    [super layoutSubviews];

    [self placeSubviews];
}

- (void)placeSubviews{}

可以看到初始化很简单只是在 init 方法中调用了 prepare 方法。而 prepare 方法也是预留给子类继承的时候可以重写的方法,用来初始化部件的,即可以在这里添加子视图。
layoutSubviews 方法中调用了 placeSubviews 方法,而 placeSubviews方法也是预留给子类继承时可以被子类重写的方法,用来将在 prepare 方法中初始化的部件进行布局的地方。

接下来有一个重写的方法

- (void)willMoveToSuperview:(UIView *)newSuperview
{
    [super willMoveToSuperview:newSuperview];

    // 如果不是UIScrollView,不做任何事情
    if (newSuperview && ![newSuperview isKindOfClass:[UIScrollView class]]) return;

    // 旧的父控件移除监听
    [self removeObservers];

    if (newSuperview) { // 新的父控件
        // 设置宽度
        self.mj_w = newSuperview.mj_w;
        // 设置位置
        self.mj_x = 0;

        // 记录UIScrollView
        _scrollView = (UIScrollView *)newSuperview;
        // 设置永远支持垂直弹簧效果
        _scrollView.alwaysBounceVertical = YES;
        // 记录UIScrollView最开始的contentInset
        _scrollViewOriginalInset = _scrollView.contentInset;

        // 添加监听
        [self addObservers];
    }
}

在代码中作者也已经很好的对每一句进行了注解,可以很容易的看懂。从中也可以看出框架具体的操作对象是 scrollView 而不是具体的 tableViewcollectionView
其中如果该 view 的父类如果是 scrollView 以及他的子类就对其添加监听。

接下来看看重点部分添加监听

#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)removeObservers
{
    [self.superview removeObserver:self forKeyPath:MJRefreshKeyPathContentOffset];
    [self.superview removeObserver:self forKeyPath:MJRefreshKeyPathContentSize];;
    [self.pan removeObserver:self forKeyPath:MJRefreshKeyPathPanState];
    self.pan = 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];
    }
}

- (void)scrollViewContentOffsetDidChange:(NSDictionary *)change{}
- (void)scrollViewContentSizeDidChange:(NSDictionary *)change{}
- (void)scrollViewPanStateDidChange:(NSDictionary *)change{}

从代码中可以看到,其监听了 scrollViewoffsetcontentSize 并且也监听了 scrollView 的手势的状态。当监听的内容发生变化,进而调用相对应的方法,同时每个方法都是空的方法,是预留给子类重写用的。那么为什么要这么做呢?因为下拉刷新和上拉加载更多的实现方式是不同的。

在基类 component 中只定义了最基本的流程:

  1. 初始化prepare
  2. 布局 placeSubviews
  3. 监听
  4. 监听的内容变化时相应的操作

而监听后,具体的操作是下拉刷新还是上拉加载更多就需要具体的子类 MJRefreshHeaderMJRefreshFooter 来实现。

Header 下拉刷新

首先看 Header 的初始化和布局

- (void)prepare
{
    [super prepare];

    // 设置key
    self.lastUpdatedTimeKey = MJRefreshHeaderLastUpdatedTimeKey;

    // 设置高度
    self.mj_h = MJRefreshHeaderHeight;
}

- (void)placeSubviews
{
    [super placeSubviews];

    // 设置y值(当自己的高度发生改变了,肯定要重新调整Y值,所以放到placeSubviews方法中设置y值)
    self.mj_y = - self.mj_h - self.ignoredScrollViewContentInsetTop;
}

初始化和布局都很简单,就是设置高度和在布局的时候设置自己的 y 坐标值。从上面的代码可以看出 Header 是以点(0,-高度)加入到 scrollView

《解读 MJRefresh 框架》

header.jpg

接下来看重点的监听的相应函数

- (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;
    }
}

这么一大段代码可能有点混乱,可先除去最上面的 if 的判断代码段先看下面的代码段。
下面的代码段其实是通过对比刚好可以显示 header 底部的 offset happenOffsetY 和 当前现在的 offset offsetY 从而计算出刷新时所处于的状态。设置 下拉的百分比 pullingPercent

现在在看设置状态的代码

- (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];
            }];
         });
    }
}

这个函数是属性 statusset 方法。目的就是在于在更换状态的时候可以显示每个状态所对应的界面。
其中我们可以知道 MJRefresh 框架是通过设置 scrollViewinset 来显示刷新用的 header 。就是在刷新状态的时候可以让 header 停留在顶部。等刷新完成即刷新状态过了,然后再设置回原来的 inset

  • 刷新前

    《解读 MJRefresh 框架》

    before.JPG
  • 刷新时

    《解读 MJRefresh 框架》

    ing.JPG
  • 刷新后

    《解读 MJRefresh 框架》

    after.JPG

现在会过头看,- (void)scrollViewContentOffsetDidChange:(NSDictionary *)change 方法,其中一开始的 if 代码段是为了解决在正在进行刷新的时候 tableView 中 sectionHeader 停留问题的。同样也是通过设置 insettop 的高度来避免这个问题的

因为下拉刷新的样式只有一种,所以 header 部分的逻辑就只有一个类就可以了,但是因为上拉加载更多有多重样式所以需要的具体的逻辑也就不同了。

Footer 上拉

MJRefreshFooter 类的代码其中只有初始化 footer 的高度,并且如果父 view 如果是 tableView 或者 collectionView 的话就添加对其数据的监控,其中重要的代码如下:

- (void)willMoveToSuperview:(UIView *)newSuperview
{
    [super willMoveToSuperview:newSuperview];

    if (newSuperview) {
        // 监听scrollView数据的变化
        if ([self.scrollView isKindOfClass:[UITableView class]] || [self.scrollView isKindOfClass:[UICollectionView class]]) {
            [self.scrollView setMj_reloadDataBlock:^(NSInteger totalDataCount) {
                if (self.isAutomaticallyHidden) {
                    self.hidden = (totalDataCount == 0);
                }
            }];
        }
    }
}

因为 Footer 有两种不同展现形式所以在 MJRefreshFooter 中没有具体的相关逻辑。而具体的显示则要在具体的子类中完成。

BackFooter 自动弹回底部

在看这个 Footer 之前,首要我们先明白的是 backFooter 是上拉出现加载 view ,然后在加载完以后就这个 view 就自动弹回底部的。就算是 tableView 或者是 collectionView 当数据量还不足以填充满整个界面的时候也是如此的。

接下来看初始化方法

#pragma mark - 初始化
- (void)willMoveToSuperview:(UIView *)newSuperview
{
    [super willMoveToSuperview:newSuperview];

    [self scrollViewContentSizeDidChange:nil];
}

- (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);
}

在上面的方法中可以看到,作者把布局的内容在 - (void)scrollViewContentSizeDidChange:(NSDictionary *)change 里面完成。因为在上拉加载完以后,数据量或许会变的更多,从而导致了 contentSize 的改变。而把布局写入该方法中,可以每次 contentSize 改变以后都可以进行正确的布局。

再继续看重点部分

- (void)scrollViewContentOffsetDidChange:(NSDictionary *)change
{
    [super scrollViewContentOffsetDidChange:change];

    // 如果正在刷新,直接返回
    if (self.state == MJRefreshStateRefreshing) return;

    _scrollViewOriginalInset = self.scrollView.contentInset;

    // 当前的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)scrollViewContentOffsetDidChange:(NSDictionary *)change 方法写的和 header 部分差不多,都是通过计算当前的 offSet 值和刚好刷新 view 顶端出现的 offSet 相比较而得到现在刷新的状态从而对自己的状态进行设置,来改变 view 的显示。

接着来看 status 的 setter 方法

- (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.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];
        }];
    }
}

status 的 setter 方法中通过不同的 status 来设置不同的样式。同时也可以看出这个 Footer 的设置方式其实和 header 的设置方式是一样的,都是通过设置 inset 来实现的。

Auto Footer 自动刷新

自动刷新方式是没有明显的上拉的动作,就是当划到最底部的时候,就自动加载。而当 tableView 或者 collectionView 的数据不足以填充满这个屏幕的时候,刷新的 view 也是现实在 最后一个数据项的下面一项。然后点击加载更多。

首先看相关的初始化方法

- (void)prepare
{
    [super prepare];

    // 默认底部控件100%出现时才会自动刷新
    self.triggerAutomaticallyRefreshPercent = 1.0;

    // 设置为默认状态
    self.automaticallyRefresh = YES;
}

- (void)scrollViewContentSizeDidChange:(NSDictionary *)change
{
    [super scrollViewContentSizeDidChange:change];

    // 设置位置
    self.mj_y = self.scrollView.mj_contentH;
}

在初始化设置中,设置了 view 的 y 坐标,刷新的 view 总是位于 tableView 或者 collectionView 的最后面。

然后看重要的逻辑处理部分

- (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;

            // 当底部刷新控件完全出现时,才刷新
            [self beginRefreshing];
        }
    }
}

- (void)scrollViewPanStateDidChange:(NSDictionary *)change
{
    [super scrollViewPanStateDidChange:change];

    if (self.state != MJRefreshStateIdle) return;

    if (_scrollView.panGestureRecognizer.state == UIGestureRecognizerStateEnded) {// 手松开
        if (_scrollView.mj_insetT + _scrollView.mj_contentH <= _scrollView.mj_h) {  // 不够一个屏幕
            if (_scrollView.mj_offsetY >= - _scrollView.mj_insetT) { // 向上拽
                [self beginRefreshing];
            }
        } else { // 超出一个屏幕
            if (_scrollView.mj_offsetY >= _scrollView.mj_contentH + _scrollView.mj_insetB - _scrollView.mj_h) {
                [self beginRefreshing];
            }
        }
    }
}

这里的逻辑写很清晰,再加上作者的注释,所以可十分轻松的就看懂了。其实里面主要的逻辑只有一小部分,而其他比较大块的代码都是在处理其他的意外情况,防止发生错误。

对于设置 status 部分和上面的情况都一样所以就不在多言。

总体而言,这个框架是在最基类里面实现了最基本的流程,而每个流程怎么处理则因为 header 和 Footer 的不同需要不同的逻辑处理。所以有 header 和 Footer 的子类。
但是由于 Footer 的上拉有两种样式和情况,所以在 Footer 的基础上又派生出两个类 backFooter 和 autoFooter 。
整个框架的逻辑就好比一辆车的制作流程,每辆车的制作流程都是相差不多的,但是就是具体在每个流程上面怎么制作。每个厂商的具体的每一步都是不一样的。

点赞