MJRefresh 源码学习笔记

MJRefresh源码学习笔记.png

1.前言

MJRefresh 是日常 iOS 开发中使用频率比较高的一款下拉刷新/上拉加载更多的第三方控件,平时似乎没有完整查看过源码,此处就记录一下探究源码的过程吧。

注:本文已同步至 个人博客

2.使用示例

官方给的 Example 里边提供了很多种刷新样式 ,本文我们只以其中 2 种样式(UITableView + 下拉刷新 动画图片UITableView + 上拉刷新动画图片 )为例展开讨论。

示例1:UITableView + 下拉刷新 动画图片

- (void)exampleA
{
    // 1.设置 header
    self.tableView.mj_header = [MJChiBaoZiHeader headerWithRefreshingTarget:self refreshingAction:@selector(loadNewData)];
    // 2.马上进入刷新状态
    [self.tableView.mj_header beginRefreshing];
}

- (void)loadNewData {
    // 3.下载数据的操作
   __weak typeof(self) weakSelf = self;
   [self requestNetDataWithCompletionBlock:^(NSArray *result, BOOL isSuccess) {
      // 处理返回的数据(略)

      // 4.刷新表格,并结束刷新状态
      [weakSelf.tableView reloadData];
      // 5.拿到当前的下拉刷新控件,
      [weakSelf.tableView.mj_header endRefreshing];
   }];
}

如上边注释所述,大概分 5 个步骤,其中 1、2、4、5 都是 MJRefresh 的相关操作。

示例2:UITableView + 上拉刷新 动画图片

- (void)exampleB
{
    // 1.设置 footer
    self.tableView.mj_footer = [MJChiBaoZiFooter footerWithRefreshingTarget:self refreshingAction:@selector(loadMoreData)];
}

- (void)loadNewData {
    // 2.下载数据的操作
   __weak typeof(self) weakSelf = self;
   [self requestNetDataWithCompletionBlock:^(NSArray *result, BOOL isSuccess) {
      // 将返回数据追加到表格的数据源(略)

      // 3.刷新表格,并结束刷新状态
      [weakSelf.tableView reloadData];
      // 4.拿到当前的上拉加载更多控件,
      [weakSelf.tableView.mj_footer endRefreshing];
   }
}

与下拉类似,这里的 1、3、4 是 MJRefresh 的相关操作。

3. 整体思路

在开始分析源码之前,我觉得应该先大概了解一下这个库的基本实现思路,这样看源码的时候才不至于晕头转向,不知所云。下面以下拉刷新为例,做一个简单介绍,先看下图。

MJRefresh整体思路.png

首先,当我们给 tableView.mj_header 赋值时,实际在 tableView 上添加一个子视图即刷新控件,但是并不是添加到 tableView 的 header 里边,因此就不会占用 tableView 的 header。

然后,对 tableView 进行监听 (KVO),当 tableView 的 contentOffset 发生变化时,刷新控件会截获到这个时机,根据 contentOffset 的 y 值更新刷新控件的显示及 tableView 的 contentInset.top,整个流程见上图。下面说说这张图吧 O(∩_∩)O:

① 刚把刷新控件加到 tableView 上的时候,设置刷新控件的 y 值为自身高度的负值,此时改控件会被导航挡住,当然也可以再设置其透明度为0。

② 下拉 tableView,当刷新控件完全显示出来(临界点)之前,是一种状态,此时松手的话,会直接弹回去。

③ 过了临界点,再往下拉的时候,更新控件的显示,此时松手的话就开始刷新。

④ 放手刷新的时候,控件会回弹,可以加动画,不至于那么生硬。同时执行调用方传入的 block,一般是请求网络数据的操作。

⑤ 刷新过程中,要显示该刷新控件,即不让其弹回到导航后边,就给 tableView.contentInset.top 增加一个控件的高度,当然是负值。

⑥ 当调用方请求完数据后,手动调用 刷新控件的 endRefreshing 方法,在这个方法类里边更新控件 UI 至初始状态,并将 tableView.contentInset.top 减少一个控件的高度,当然也是负值,至此,刷新结束。

以上就基本实现逻辑,下面开始看源码吧。

4. 源码分析

下面分别探究一下下拉刷新和上拉加载更多的源码实现。

通过 示例1示例2 可以推测,这个框架可以大概分 2 部分,一部分是刷新控件的载体 (UIScrollView及其子类,即 tableView 和 collectionView),另一部分就是刷新控件本身,也就是所谓的 header 和 footer。

4.1 刷新控件的载体

载体主要集中在下边这几个分类里边

UIScrollView+MJExtension
UIScrollView+MJRefresh
UIView+MJExtension

UIView+MJExtension

UIView+MJExtension 只是为公共基类 UIView 的 frame 提供了便捷的访问方式,包括刷新控件也会用到。

UIScrollView+MJRefresh

UIScrollView+MJRefresh 是列表基类的分类,这个文件里边实际包含了 3 个分类,依次为

① NSObject (MJRefresh):分别提供了交换类方法和交换实例方法的工具方法:

/// 交换实例方法
+ (void)exchangeInstanceMethod1:(SEL)method1 method2:(SEL)method2
{
    method_exchangeImplementations(class_getInstanceMethod(self, method1), class_getInstanceMethod(self, method2));
}
/// 交换类方法
+ (void)exchangeClassMethod1:(SEL)method1 method2:(SEL)method2
{
    method_exchangeImplementations(class_getClassMethod(self, method1), class_getClassMethod(self, method2));
}

② UIScrollView (MJRefresh):依次为基类 UIScrollView 添加了 header、footer 和 mj_reloadDataBlock 这三个属性,并利用关联对象添加了对应的 setter 和 getter 实现,关于在既有类中使用关联对象存放自定义数据的方法,可以查阅《Effective Objective-C 2.0》中第 10 条的介绍。

其中,在 mj_reloadDataBlocksetter 中设置关联对象前后分别添加了 willChangeValueForKey:didChangeValueForKey: 这 2 个方法,意在可以添加 KVO 监听。

- (void)setMj_reloadDataBlock:(void (^)(NSInteger))mj_reloadDataBlock
{
    [self willChangeValueForKey:@"mj_reloadDataBlock"]; // KVO
    objc_setAssociatedObject(self, &MJRefreshReloadDataBlockKey, mj_reloadDataBlock, OBJC_ASSOCIATION_COPY_NONATOMIC);
    [self didChangeValueForKey:@"mj_reloadDataBlock"]; // KVO
}

然后提供了一个执行 mj_reloadDataBlock 的方法 executeReloadDataBlock:

- (void)executeReloadDataBlock
{
    !self.mj_reloadDataBlock ? : self.mj_reloadDataBlock(self.mj_totalDataCount);
}

即如果设置了 mj_reloadDataBlock ,就在此执行这个 block,我们注意到这个参数 mj_totalDataCount,点开后发现,原来它指的是 UITableView 或 UICollectionView 的总行数。

- (NSInteger)mj_totalDataCount
{
    NSInteger totalCount = 0;
    if ([self isKindOfClass:[UITableView class]]) {
        UITableView *tableView = (UITableView *)self;
        
        for (NSInteger section = 0; section<tableView.numberOfSections; section++) {
            totalCount += [tableView numberOfRowsInSection:section];
        }
    } else if ([self isKindOfClass:[UICollectionView class]]) {
        UICollectionView *collectionView = (UICollectionView *)self;
        
        for (NSInteger section = 0; section<collectionView.numberOfSections; section++) {
            totalCount += [collectionView numberOfItemsInSection:section];
        }
    }
    return totalCount;
}

③④ UITableView (MJRefresh) 和 UICollectionView (MJRefresh),他们都提供了下边两个方法:

+ (void)load
{
    [self exchangeInstanceMethod1:@selector(reloadData) method2:@selector(mj_reloadData)];
}

- (void)mj_reloadData
{
    [self mj_reloadData];
    [self executeReloadDataBlock];
}

也就是说,在程序启动时执行 load 方法时将列表的 reloadData 方法与自定义的 mj_reloadData 方法交换,在新方法中增加了一步操作 [self executeReloadDataBlock];,即执行上文提到的 mj_reloadDataBlock,这样,当我们执行 tableView 的 reloadData 方法时,实际执行的就是 mj_reloadData 这个方法了。

那么这个 block 是什么时候设置的呢,全局搜索了一下,发现只有在 MJRefreshFooterwillMoveToSuperview: 方法中设置过,也就是将 footer 添加到 tableView 上的时候。

- (void)willMoveToSuperview:(UIView *)newSuperview
{
    [super willMoveToSuperview:newSuperview];
    
    // 当前视图被添加到父视图上时,设置后边的 block,即当列表(UICollectionView 或 UITableView)行数为 0 时,隐藏当前视图。
    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);
                }
            }];
        }
    }
}

willMoveToSuperview: 是将视图添加到父视图或从父视图中移除时调用的,if (newSuperview) { ... } 说明是将 footer 添加到父视图上的时候设置这个 block 的,block 的具体实现是:如果需要自动隐藏,则当数据的总条数为 0 时,隐藏 footer,否则展示。

UIScrollView+MJExtension

UIScrollView+MJExtension 是关于下边几个属性的便捷访问方式:

contentInset / adjustedContentInset
contentOffset
contentSize

其中,adjustedContentInset 是 iOS 11 新引入的一个 属性,在 iOS 11 中决定 tableView 的内容与边缘距离的是 adjustedContentInset 属性,而不是 contentInset。

4.2下拉刷新控件(refreshHeader)

先来看一张图:

Header 继承体系.png

上图就是 header 的继承关系,示例中的 MJChiBaoZiHeader 就是继承自 MJRefreshGifHeader。为了描述更有条理,我们从基类开始讨论吧。

MJRefreshComponent

MJRefreshComponent 是所有 header 和 footer 的基类,这里定义了表示刷新状态的枚举 MJRefreshState 和 3 种不同的回调。

/** 刷新控件的状态 */
typedef NS_ENUM(NSInteger, MJRefreshState) {
    /** 普通闲置状态 */
    MJRefreshStateIdle = 1,
    /** 松开就可以进行刷新的状态 */
    MJRefreshStatePulling,
    /** 正在刷新中的状态 */
    MJRefreshStateRefreshing,
    /** 即将刷新的状态 */
    MJRefreshStateWillRefresh,
    /** 所有数据加载完毕,没有更多的数据了 */
    MJRefreshStateNoMoreData
};

/** 进入刷新状态的回调 */
typedef void (^MJRefreshComponentRefreshingBlock)(void);
/** 开始刷新后的回调(进入刷新状态后的回调) */
typedef void (^MJRefreshComponentbeginRefreshingCompletionBlock)(void);
/** 结束刷新后的回调 */
typedef void (^MJRefreshComponentEndRefreshingCompletionBlock)(void);

实际上这个类的文件里有两个类,除了自己之外还有一个 UILabel 的分类,提供了一个创建定制好的 Label 的类方法 mj_label 和获取文本宽度的实例方法 mj_textWith

+ (instancetype)mj_label
{
    UILabel *label = [[self alloc] init];
    label.font = MJRefreshLabelFont;
    label.textColor = MJRefreshLabelTextColor;
    label.autoresizingMask = UIViewAutoresizingFlexibleWidth;
    label.textAlignment = NSTextAlignmentCenter;
    label.backgroundColor = [UIColor clearColor];
    return label;
}

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

下面来看看 MJRefreshComponent 这个类吧,依照惯例,从初始化方法开始:

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

初始化了几个变量:

①初始状态设置为普通状态,既没有触发刷新的情况;

prepare方法中设置了两个基本属性,backgroundColorautoresizingMask,autoresizingMask 的初值 UIViewAutoresizingFlexibleWidth 指的是:当父视图的 bounds 改变时,子视图 (即当前视图) 自动调整宽度,以保证左、右边距不变。

关于布局,这里重写了 layoutSubviews 方法,在调用 super 的方法之前增加了一步操作 placeSubviews,这个方法需要子类来实现。

- (void)layoutSubviews
{
    [self placeSubviews];
    
    [super layoutSubviews];
}

- (void)placeSubviews {
    
}

重写了 willMoveToSuperview: 这个方法用于将当前视图添加到父视图或从父视图中移除时添加一些额外操作。

- (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 = -_scrollView.mj_insetL;
        
        // 记录UIScrollView
        _scrollView = (UIScrollView *)newSuperview;
        // 设置永远支持垂直弹簧效果
        _scrollView.alwaysBounceVertical = YES;
        // 记录UIScrollView最开始的contentInset
        _scrollViewOriginalInset = _scrollView.mj_inset;
        
        // 添加监听
        [self addObservers];
    }
}

首先对 newSuperview 做了一层过滤,只有是 UIScrollView 及其子类才可以继续往下走。

然后,先将旧的监听移除。

如果是移除当前视图的操作,则会跳过下边的 if 代码,结束这个方法的执行。如果是将当前视图添加父视图上,即父视图 newSuperview 存在时,保存一些值,最后添加新的监听。

下面看看添加、移除监听的这波操作:

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

监听主要是针对 self.ScrollView 即父视图的 contentOffset、contentSize 和 父视图的 panGestureRecognizer 的 state。

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

当监听到变换时,会分别触发对应的处理方法(下边 3 个),其中 scrollViewContentOffsetDidChange: 在下拉刷新和上拉加载更多时都会用到,后边两个方法只在上拉加载更多时会用到。

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

接下来是一批公共方法:

① 设置回调对象和回调方法,提供了一种内部的响应方式,即使用 target-Action 的方式,执行刷新回调 executeRefreshingCallback 时候用到,见下边的内部方法。

- (void)setRefreshingTarget:(id)target refreshingAction:(SEL)action
{
    self.refreshingTarget = target;
    self.refreshingAction = action;
}

#pragma mark - 内部方法

- (void)executeRefreshingCallback
{
    MJRefreshDispatchAsyncOnMainQueue({
        
        if (self.refreshingBlock) {
            self.refreshingBlock();
        }
        
        // #define MJRefreshMsgSend(...) ((void (*)(void *, SEL, UIView *))objc_msgSend)(__VA_ARGS__)
        // #define MJRefreshMsgTarget(target) (__bridge void *)(target)
        if ([self.refreshingTarget respondsToSelector:self.refreshingAction]) {
            MJRefreshMsgSend(MJRefreshMsgTarget(self.refreshingTarget), self.refreshingAction, self);
        }
        
        if (self.beginRefreshingCompletionBlock) {
            self.beginRefreshingCompletionBlock();
        }
    })
}

接下来是一个很重要的 setter,子类可以重写该方法,在状态方法改变的时候,及时更新刷新控件。

- (void)setState:(MJRefreshState)state
{
    _state = state;
    
    // 加入主队列的目的是等setState:方法调用完毕、设置完文字后再去布局子控件
    MJRefreshDispatchAsyncOnMainQueue([self setNeedsLayout];)
}

② 进入刷新及结束刷新的方法,每种情况分别提供了一个带 block 和一个不带 block 的方法,后者保存了 block 之后,又调用了前者,这个 block 的作用是用来添加刷新结束后的附加操作的。

#pragma mark 进入刷新状态

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

- (void)beginRefreshingWithCompletionBlock:(void (^)(void))completionBlock
{
    self.beginRefreshingCompletionBlock = completionBlock;
    
    [self beginRefreshing];
}

#pragma mark 结束刷新状态

- (void)endRefreshing
{
    MJRefreshDispatchAsyncOnMainQueue(self.state = MJRefreshStateIdle;)
}

- (void)endRefreshingWithCompletionBlock:(void (^)(void))completionBlock
{
    self.endRefreshingCompletionBlock = completionBlock;
    
    [self endRefreshing];
}

③ 最后是根据拖拽进度自动改变透明度的相关方法,即如果需要自动改变透明度,则会在拖拽过程中,将拖拽进度时时赋值给 self.alpha。

#pragma mark 自动切换透明度

- (void)setAutoChangeAlpha:(BOOL)autoChangeAlpha
{
    self.automaticallyChangeAlpha = autoChangeAlpha;
}

- (BOOL)isAutoChangeAlpha
{
    return self.isAutomaticallyChangeAlpha;
}

- (void)setAutomaticallyChangeAlpha:(BOOL)automaticallyChangeAlpha
{
    _automaticallyChangeAlpha = automaticallyChangeAlpha;
    
    if (self.isRefreshing) return;
    
    if (automaticallyChangeAlpha) {
        self.alpha = self.pullingPercent;
    } else {
        self.alpha = 1.0;
    }
}

#pragma mark 根据拖拽进度设置透明度

- (void)setPullingPercent:(CGFloat)pullingPercent
{
    _pullingPercent = pullingPercent;
    
    // 不是正在刷新的状态,而且要求自动改变透明度时,将 pullingPercent 的值给 alpha,否则不再往下执行。
    
    if (self.isRefreshing) return;
    
    if (self.isAutomaticallyChangeAlpha) {
        self.alpha = pullingPercent;
    }
}

MJRefreshHeader

MJRefreshHeaderMJRefreshComponent 的子类,但还不是最终可以使用的类,还在为其子类做准备。先来看看它提供的两个构造方法,在创建实例对象的同时,保存了响应的回调,一个采用 block,另一个采用 target-action 的方式。

#pragma mark - 构造方法

+ (instancetype)headerWithRefreshingBlock:(MJRefreshComponentRefreshingBlock)refreshingBlock
{
    MJRefreshHeader *cmp = [[self alloc] init];
    cmp.refreshingBlock = refreshingBlock;
    return cmp;
}

+ (instancetype)headerWithRefreshingTarget:(id)target refreshingAction:(SEL)action
{
    MJRefreshHeader *cmp = [[self alloc] init];
    [cmp setRefreshingTarget:target refreshingAction:action];
    return cmp;
}

下边是重写父类的方法,首先在 prepare 和 `` 方法中设置存取刷新时刻用的 key 、自身高度 mj_h 及 自身的 y 坐标 mj_y。这里出现了一个 ignoredScrollViewContentInsetTop,推测是一个预留的 refreshHeader 和 tableView 之间的间隙值,默认为 0,需要用户设置才会有值。

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

然后是 2 个重要的方法,scrollViewContentOffsetDidChange:setState:

scrollViewContentOffsetDidChange: 方法主要是根据当前状态 (self.state) 和 contentOffset 更新 self.state,之所以要考虑当前状态,是为了避免频繁的更新 state 的值,详见代码注释。

setState: 方法针对 进入刷新状态从刷新恢复正常状态 分别进行处理,前者话,将 scrollView 的 contentInset.top 加上一个 refreshHeader 的高度,对于后者,又需要将之前加上的高度减掉,以此来控制刷新控件的悬浮状态。另外, 从刷新恢复正常状态 时,保存了当前时刻,这个是为了显示上一次刷新时间用的。

最后是 2 个公共方法,一个用来获取保存在本地的上次刷新时间,另一个是 ignoredScrollViewContentInsetTop 的setter,同时更新了刷新控件的 y 值。

MJRefreshStateHeader

MJRefreshStateHeader 也属于这个继承体系中的一员,继承自 MJRefreshHeader,这里开始就到实用阶段了:

  • 在 prepare 方法中将三种状态对应的标签保存到一个可变字典(stateTitles)中,以备后边展示。
- (void)prepare {
    [super 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];
}

- (void)setTitle:(NSString *)title forState:(MJRefreshState)state {
    if (title == nil) return;
    self.stateTitles[@(state)] = title;
    self.stateLabel.text = self.stateTitles[@(self.state)];
}
  • 用懒加载的方式为 header 添加了两个标签,分别用于展示状态提示文案和上次刷新的时间;
/** 显示刷新状态的label */
- (UILabel *)stateLabel {
    if (!_stateLabel) {
        [self addSubview:_stateLabel = [UILabel mj_label]];
    }
    return _stateLabel;
}

/** 显示上一次刷新时间的label */
- (UILabel *)lastUpdatedTimeLabel {
    if (!_lastUpdatedTimeLabel) {
        [self addSubview:_lastUpdatedTimeLabel = [UILabel mj_label]];
    }
    return _lastUpdatedTimeLabel;
}
  • setState: 方法中为 2 个标签分别赋值,stateLabel 根据 state 从之前保存的可变字典(stateTitles)中取值(这里作者做了本地化处理),lastUpdatedTimeLabel 的赋值有点特别,他是在 setLastUpdatedTimeKey: 方法中对时间进行格式化等相关处理后赋值给 lastUpdatedTimeLabel,所以每次更新 state 的时候要调用用一次 self.lastUpdatedTimeKey = self.lastUpdatedTimeKey
- (void)setState:(MJRefreshState)state
{
    MJRefreshCheckState
    // 设置状态文字
    self.stateLabel.text = self.stateTitles[@(state)];
    // 重新设置key(重新显示时间)
    self.lastUpdatedTimeKey = self.lastUpdatedTimeKey;
}

MJRefreshGifHeader

MJRefreshGifHeader 继承自上一个类,并增加了一个用于展示动画图片的 imageView 及用于保存各种状态对应的动画图片和动画时间字典。

__unsafe_unretained UIImageView *_gifView;

/** 所有状态对应的动画图片 */
@property (strong, nonatomic) NSMutableDictionary *stateImages;
/** 所有状态对应的动画时间 */
@property (strong, nonatomic) NSMutableDictionary *stateDurations;

为了获取这些动画图片和时间,并与对应的状态关联起来,提供了 2 个供外界调用的方法,图片必须提供,时间可以不传(第二种方法),会去默认值 images.count * 0.1

- (void)setImages:(NSArray *)images duration:(NSTimeInterval)duration forState:(MJRefreshState)state 
{ 
    if (images == nil) return; 
    
    self.stateImages[@(state)] = images; 
    self.stateDurations[@(state)] = @(duration); 
    
    /* 根据图片设置控件的高度 */ 
    UIImage *image = [images firstObject]; 
    if (image.size.height > self.mj_h) { 
        self.mj_h = image.size.height; 
    } 
}

- (void)setImages:(NSArray *)images forState:(MJRefreshState)state 
{ 
    [self setImages:images duration:images.count * 0.1 forState:state]; 
}

这里重写了父类的 setState: 方法,当拖拽或刷新的时候才会去设置动画图片,首先停止之前的动画,然后再设置新值,如果是单张图片,直接展示,多张情况才需要展示动画; 如果 state 是正常状态,则停止动画。

- (void)setState:(MJRefreshState)state
{
    MJRefreshCheckState
    
    // 根据状态做事情
    if (state == MJRefreshStatePulling || state == MJRefreshStateRefreshing) {
        NSArray *images = self.stateImages[@(state)];
        if (images.count == 0) return;
        
        [self.gifView stopAnimating];
        if (images.count == 1) { // 单张图片
            self.gifView.image = [images lastObject];
        } else { // 多张图片
            self.gifView.animationImages = images;
            self.gifView.animationDuration = [self.stateDurations[@(state)] doubleValue];
            [self.gifView startAnimating];
        }
    } else if (state == MJRefreshStateIdle) {
        [self.gifView stopAnimating];
    }
}

MJChiBaoZiHeader

先来看看 prepare 的实现,只重写了父类的一个方法:

- (void)prepare
{
    // 0.执行父类的 prepare 方法
    [super prepare];
    
    // 1.设置普通状态的动画图片
    NSMutableArray *idleImages = [NSMutableArray array];
    for (NSUInteger i = 1; i<=60; i++) {
        UIImage *image = [UIImage imageNamed:[NSString stringWithFormat:@"dropdown_anim__000%zd", i]];
        [idleImages addObject:image];
    }
     [self setImages:idleImages forState:MJRefreshStateIdle];
    
    // 2.设置即将刷新状态的动画图片(一松开就会刷新的状态)
    NSMutableArray *refreshingImages = [NSMutableArray array];
    for (NSUInteger i = 1; i<=3; i++) {
        UIImage *image = [UIImage imageNamed:[NSString stringWithFormat:@"dropdown_loading_0%zd", i]];
        [refreshingImages addObject:image];
    }
    [self setImages:refreshingImages forState:MJRefreshStatePulling];
    
    // 3.设置正在刷新状态的动画图片
    [self setImages:refreshingImages forState:MJRefreshStateRefreshing];
}

具体实现见上边的代码注释,其中 preparesetImages: forState: 两个方法均来自基类,上边已经介绍过了。

4.3 上拉加载更多控件(refreshFooter)

与 header 类似,先看一下类的继承关系:

Footer继承体系.png

既然都是继承自 MJRefreshComponent,这里就直接从 MJRefreshFooter 开始讨论,然后准者继承体系一直讲到 MJChiBaoZiFooter,即示例 2 用到的 footer。

MJRefreshFooter

观察这个类的源码就会发现,他和 MJRefreshHeader 有许多相似之处,比如都提供了两个构造方法,一个用 block ,一个用 target-Action。下面主要说下不一样的地方。

有一个自动根据有无数据来显示和隐藏 footer 的属性 automaticallyHidden,不过作者不建议使用,而且后期可能会移除。不过,还是假名单介绍一下吧。

@property (assign, nonatomic, getter=isAutomaticallyHidden) BOOL automaticallyHidden

viewWillMoveToSuperView: 中设置 footer 隐藏与否的时候会用到这个属性,详见前边 4.1刷新控件的载体 的介绍,这里不再复述。

最后看 2 个公共方法,废弃的那个就不列出来了O(∩_∩)O:

/** 提示没有更多的数据 */
- (void)endRefreshingWithNoMoreData {
    MJRefreshDispatchAsyncOnMainQueue(self.state = MJRefreshStateNoMoreData;)
}

/** 重置没有更多的数据(消除没有更多数据的状态) */
- (void)resetNoMoreData {
    MJRefreshDispatchAsyncOnMainQueue(self.state = MJRefreshStateIdle;)
}

MJRefreshAutoFooter

MJRefreshAutoFooterMJRefreshFooter 的直接子类,并不是可以直接使用的类,还是在为子类提供方便,这里需要重点介绍一下。

  • 提供了几个公开的属性,其作用见下方注释。
#pragma mark - 公开的属性

/** 是否自动刷新(默认为YES,即达到一定的触发条件就会自动开始刷新) */
@property (assign, nonatomic, getter=isAutomaticallyRefresh) BOOL automaticallyRefresh;

/** 当底部控件出现多少时就自动刷新(默认为1.0,也就是底部控件完全出现时,才会自动刷新) */
@property (assign, nonatomic) CGFloat triggerAutomaticallyRefreshPercent;

/** 是否每一次拖拽只发一次请求,手没有离开屏幕的情况下反复拖拽的话,不会触发多次刷新 */
@property (assign, nonatomic, getter=isOnlyRefreshPerDrag) BOOL onlyRefreshPerDrag;

#pragma mark - 私有的属性

/** 是否是一个新的拖拽 */
@property (assign, nonatomic, getter=isOneNewPan) BOOL oneNewPan;
  • 重写了 willMoveToSuperView 方法,当添加到父控件上时,给 scrollView.contentInset.bottom 添加一个 footer 本身的高度,反之,从父控件上移除时,又要将之前加上的再减掉。
- (void)willMoveToSuperview:(UIView *)newSuperview {
    [super willMoveToSuperview:newSuperview];
    
    if (newSuperview) { // 添加到父控件上
        
        if (self.hidden == NO) {
            self.scrollView.mj_insetB += self.mj_h;
        }
        self.mj_y = _scrollView.mj_contentH; // 设置位置
        
    } else {            // 被移除了
        
        if (self.hidden == NO) {
            self.scrollView.mj_insetB -= self.mj_h;
        }
    }
}
  • 在准备数据阶段设置了这么几个初值:
- (void)prepare {
    [super prepare];
    
    // 默认底部控件100%出现时才会自动刷新
    self.triggerAutomaticallyRefreshPercent = 1.0;
    
    // 设置为默认状态
    self.automaticallyRefresh = YES;
    
    // 默认是当offset达到条件就发送请求(可连续)
    self.onlyRefreshPerDrag = NO;
}
  • 下面是对 scrollView 的监听触发的 3 个事件:

当 scrollView 的 contentSize 发生变化的时候,计时更新 footer 的 y 值,保证它一直贴着 scrollView 的下边沿。

- (void)scrollViewContentSizeDidChange:(NSDictionary *)change {
    [super scrollViewContentSizeDidChange:change];
    
    // 设置位置
    self.mj_y = self.scrollView.mj_contentH;
}

当滑动 scrollView 产生 contentOffset 的时候,控制当底部刷新控件完全出现的时候,才能刷新。

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

当手势的状态发生变化的时候,针对 UIGestureRecognizerStateEndedUIGestureRecognizerStateBegan 两种状态进行处理,对于前者,根据 contentOffset.y 决定开始刷新的时机,后者的话,就认为是一个新的手势开始了。

- (void)scrollViewPanStateDidChange:(NSDictionary *)change
{
    [super scrollViewPanStateDidChange:change];
    
    if (self.state != MJRefreshStateIdle) return;
    
    UIGestureRecognizerState panState = _scrollView.panGestureRecognizer.state;
    if (panState == 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];
            }
        }
    } else if (panState == UIGestureRecognizerStateBegan) {
        self.oneNewPan = YES;
    }
}
  • 当然也会重写 setState 方法,如果是刷新状态,就执行刷新的回调;如果是从刷新状态变成没有更多数据或停止刷新的状态,则执行停止刷新完成的 block。
- (void)setState:(MJRefreshState)state
{
    MJRefreshCheckState
    
    if (state == MJRefreshStateRefreshing) {
        
        // 刷新状态,执行刷新的回调
        [self executeRefreshingCallback];
        
    } else if (state == MJRefreshStateNoMoreData || state == MJRefreshStateIdle) {
        
        // 从 刷新状态 进入 没有更多数据或者正常状态 时,如果有完成后的回调,则执行之
        if (MJRefreshStateRefreshing == oldState) {
            if (self.endRefreshingCompletionBlock) {
                self.endRefreshingCompletionBlock();
            }
        }
    }
}
  • 最后是 setHidden: 方法,根据显示隐藏的变化,调整 contentInsetstateself.frame.origin.y
- (void)setHidden:(BOOL)hidden
{
    BOOL lastHidden = self.isHidden;
    
    [super setHidden:hidden];
    
    // 从显示变成隐藏状态
    if (!lastHidden && hidden) {
        
        self.state = MJRefreshStateIdle;
        self.scrollView.mj_insetB -= self.mj_h;
        
    } else if (lastHidden && !hidden) {
        
        // 从隐藏变成显示状态
        
        self.scrollView.mj_insetB += self.mj_h;
        // 设置位置
        self.mj_y = _scrollView.mj_contentH;
    }
}

MJRefreshAutoStateFooter

MJRefreshAutoStateFooter 继承自 MJRefreshAutoFooterMJRefreshStateHeader 类似,从这里开始介入具体的 UI,既可以直接使用了,这里只介绍与 MJRefreshStateHeader 不同的地方。

  • 只有一个显示刷新状态的 stateLabel 和 保存不同状态下文案的可变字典 stateTitles
/** 显示刷新状态的label */
__unsafe_unretained UILabel *_stateLabel;

/** 所有状态对应的文字 */
@property (strong, nonatomic) NSMutableDictionary *stateTitles;
  • 重写父类 prepare 方法时,除了保存各种状态的本地化文案外,还给 stateLabel 添加了点击手势。
- (void)prepare
{
    [super prepare];
    
    // 初始化间距
    self.labelLeftInset = MJRefreshLabelLeftInset;
    
    // 初始化文字
    [self setTitle:[NSBundle mj_localizedStringForKey:MJRefreshAutoFooterIdleText] forState:MJRefreshStateIdle];
    [self setTitle:[NSBundle mj_localizedStringForKey:MJRefreshAutoFooterRefreshingText] forState:MJRefreshStateRefreshing];
    [self setTitle:[NSBundle mj_localizedStringForKey:MJRefreshAutoFooterNoMoreDataText] forState:MJRefreshStateNoMoreData];
    
    // 监听label
    self.stateLabel.userInteractionEnabled = YES;
    [self.stateLabel addGestureRecognizer:[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(stateLabelClick)]];
}

点击 stateLabel 的时候,如果是正常未刷新的状态,则开始刷新。

- (void)stateLabelClick {
    if (self.state == MJRefreshStateIdle) {
        [self beginRefreshing];
    }
}
  • 重写 setState: 方法的时候,增加刷新过程中对 stateLabel 显示与隐藏的控制。
- (void)setState:(MJRefreshState)state
{
    MJRefreshCheckState
    
    if (self.isRefreshingTitleHidden && state == MJRefreshStateRefreshing) {
        self.stateLabel.text = nil;
    } else {
        self.stateLabel.text = self.stateTitles[@(state)];
    }
}

MJRefreshAutoGifFooter

MJRefreshAutoGifFooter 继承自 MJRefreshAutoStateFooter,仔细查看其实现代码,就会发现与 MJRefreshGifHeader 非常类似,都是在父类基础上加了一个 gifView(UIImageView) 用于展示动画图片,其他操作也基本类似,只是增加了没有更多数据的状态 MJRefreshStateNoMoreData 以及 gifViewstateLabel 的显隐控制。

MJChiBaoZiFooter

MJChiBaoZiFooter 里边也重写了父类的 prepare 方法,调用了父类的 setImages: forState: 方法用于设置创新状态时的动画图片,至于其方法实现,见父类。

- (void)prepare
{
    [super prepare];
    
    // 设置正在刷新状态的动画图片
    NSMutableArray *refreshingImages = [NSMutableArray array];

    for (NSUInteger i = 1; i<=3; i++) {
        UIImage *image = [UIImage imageNamed:[NSString stringWithFormat:@"dropdown_loading_0%zd", i]];
        [refreshingImages addObject:image];
    }

    [self setImages:refreshingImages forState:MJRefreshStateRefreshing];
}

5.小结

本文只是对 MJRefresh 源码的一个简单讨论,很多细节还没有讲的很透,后期会及时更新这部分内容。

6.参考

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,793评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,567评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,342评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,825评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,814评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,680评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,033评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,687评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,175评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,668评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,775评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,419评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,020评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,978评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,206评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,092评论 2 351
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,510评论 2 343

推荐阅读更多精彩内容

  • 发现 关注 消息 iOS 第三方库、插件、知名博客总结 作者大灰狼的小绵羊哥哥关注 2017.06.26 09:4...
    肇东周阅读 12,019评论 4 62
  • MJRefresh 已经很久没有写技术文章了,之前一段时间确实也是很忙,当然这也是一个借口,自己不思进取的成分也有...
    雨雪传奇阅读 1,124评论 0 4
  • MJRefresh是李明杰老师的作品,到现在已经有9800多颗star了,是一个简单实用,功能强大的iOS下拉刷新...
    Style_mao阅读 647评论 1 2
  • 2017年11月11日 星期六 晴 天越来越冷,出门时需要穿羽绒服戴帽子,在外面玩的时间也缩短了不少,我珍惜在...
    格子记阅读 249评论 1 0
  • 《英伦对决》21 日在北京举行「重返好莱坞」发布会,「功夫之王」与「特工之王007」的巅峰对决,更是让观众充满了无...
    老金博客阅读 250评论 0 1