iOS-MJRefresh框架

李明杰的MJRefresh应该也算是iOS中使用最广泛的一个框架了,而且MJ的框架也用了好多中文注释,这点让我感觉到很亲切,网上也有好多分析的文章,但是别人的毕竟没自己的印象深刻,现在分析一下MJRefresh每一句代码是干什么的?为什么要这么写?

想要写一个好的框架需要注意两点:1. 易用性强 2. 可定制性强 3. 设计合理
易用性强才会有人愿意使用,可定制性强才会有更多的场景可以使用,设计合理以后修改的时候才不会太麻烦,别人也很容易理解。

一. 如何实现下拉刷新

  1. 利用contentOffset
    首先如何实现下拉刷新,很显然下拉的时候scrollView 的contentOffset会改变,我们可以监听这个值的变化来给scrollView添加一个mj_header并实现相应的动画效果。
  2. 如何添加mj_header
    现在目标是给scrollView添加一个MJRefreshHeader,只要给scrollView添加MJRefreshHeader,其他tableView和collectionView就都有了,但是系统的scrollView没有这个属性,这时候我们可以通过给scrollView的分类添加关联对象的方式,来实现给scrollView添加一个属性,具体可参考UIScrollView+MJRefresh代码。
  3. mj_header添加在contentInset.top的位置。
  4. 为什么要有MJRefreshComponent
    接下来我们就想如何去写一个MJRefreshHeader,显然,我们不但有下拉刷新还有上拉刷新,这时候我们就需要一个baseView,这个baseView就是MJRefreshComponent,我们的上拉和下拉刷新控件都继承于这个MJRefreshComponent,MJRefreshComponent继承于UIView是最基础的基类,所以关于上拉下拉所有唯一共用的东西我们都可以写在这里面。

接下来的事情就很简单了,我们可以层层继承,在合适的类添加合适的控件实现合适的方法。

二. MJRefreshComponent

1. 定义的东西和成员变量

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

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

/** 刷新控件的基类 */
@interface MJRefreshComponent : UIView
{
    /** 记录scrollView刚开始的inset */
    UIEdgeInsets _scrollViewOriginalInset;
    /** 父控件 */
    __weak UIScrollView *_scrollView;
}
  1. 首先定义了刷新状态MJRefreshState和三个刷新状态的block,这个很容易理解,每个刷新控件一定有这些东西。
  2. 另外还定义了两个成员变量
    ① _scrollViewOriginalInset这个值记录scrollView刚开始的inset,这个值会在scrollViewContentOffsetDidChange方法里面使用到,用来设置mj_header的位置。
    ② _scrollView就是父控件,弱引用。

2. 刷新回调

#pragma mark - 刷新回调
/** 正在刷新的回调 */
@property (copy, nonatomic) MJRefreshComponentRefreshingBlock refreshingBlock;
/** 设置回调对象和回调方法 */
- (void)setRefreshingTarget:(id)target refreshingAction:(SEL)action;

/** 回调对象 */
@property (weak, nonatomic) id refreshingTarget;
/** 回调方法 */
@property (assign, nonatomic) SEL refreshingAction;
/** 触发回调(交给子类去调用) */
- (void)executeRefreshingCallback;

这里定义了刷新回调,以及回调方法和回调对象,主要介绍executeRefreshingCallback方法:

#pragma mark - 内部方法
//执行刷新回调,不同子类都会调用,所以抽取到父类
- (void)executeRefreshingCallback
{
    MJRefreshDispatchAsyncOnMainQueue({
        if (self.refreshingBlock) {
            self.refreshingBlock();
        }
        if ([self.refreshingTarget respondsToSelector:self.refreshingAction]) {
            //消息发送机制:
            //((void (*)(void *, SEL, UIView *))objc_msgSend)((__bridge void *)(self.refreshingTarget), self.refreshingAction, self);
            MJRefreshMsgSend(MJRefreshMsgTarget(self.refreshingTarget), self.refreshingAction, self);
        }
        if (self.beginRefreshingCompletionBlock) {
            self.beginRefreshingCompletionBlock();
        }
    })
}

这个方法执行刷新回调,不同子类都会调用,所以抽取到父类里面。

3. 刷新状态控制

#pragma mark - 刷新状态控制
/** 进入刷新状态 */
- (void)beginRefreshing;
- (void)beginRefreshingWithCompletionBlock:(void (^)(void))completionBlock;
/** 开始刷新后的回调(进入刷新状态后的回调) */
@property (copy, nonatomic) MJRefreshComponentbeginRefreshingCompletionBlock beginRefreshingCompletionBlock;
/** 结束刷新的回调 */
@property (copy, nonatomic) MJRefreshComponentEndRefreshingCompletionBlock endRefreshingCompletionBlock;
/** 结束刷新状态 */
- (void)endRefreshing;
- (void)endRefreshingWithCompletionBlock:(void (^)(void))completionBlock;
/** 是否正在刷新 */
@property (assign, nonatomic, readonly, getter=isRefreshing) BOOL refreshing;
//- (BOOL)isRefreshing;
/** 刷新状态 一般交给子类内部实现 */
@property (assign, nonatomic) MJRefreshState state;

这里定义了开始结束刷新的方法以及开始结束刷新的block,定义了刷新状态以及是否正在刷新的BOOL值来控制刷新状态。

① setState:

一般交给子类内部实现,不同状态做不同的事情。

- (void)setState:(MJRefreshState)state
{
    _state = state;
    
    // 加入主队列的目的是等setState:方法调用完毕、设置完文字后再去布局子控件
    //因为文字的变化会引起左侧箭头位置的变化,这时候需要刷新来重制位置。
    MJRefreshDispatchAsyncOnMainQueue([self setNeedsLayout];)
}

② beginRefreshing:

他其实主要就是把state标记为MJRefreshStateRefreshing。但是它还做了另外一层判断:window的有无。MJ 也做了备注,说明了为什么要有这个判断,主要是因为预防用户过早的调用了beginRefresh方法,然而这时候自身还并没有显示出来,所以巧妙的先将state标记为了MJRefreshStateWillRefresh。

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

4. 交给子类实现

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

这些方法主要是交给子类来实现,这里只实现了prepare方法:

- (void)prepare
{
    // 基本属性
    //保证在横竖屏切换的时候能够保证自身相对于父视图的左右边距保持不变,这个方法是每个子类都必须的,所以放在了基类MJRefreshComponent中。
    self.autoresizingMask = UIViewAutoresizingFlexibleWidth;
    self.backgroundColor = [UIColor clearColor];
}

方法的调用顺序是:

alloc(prepare) -> setState -> willMoveToSuperView -> layoutSubViews(placeSubviews) -> drawRect

5. 拖拽百分比和透明度

#pragma mark - 其他
/** 拉拽的百分比(交给子类重写) */
@property (assign, nonatomic) CGFloat pullingPercent;
/** 根据拖拽比例自动切换透明度 */
@property (assign, nonatomic, getter=isAutoChangeAlpha) BOOL autoChangeAlpha MJRefreshDeprecated("请使用automaticallyChangeAlpha属性");
/** 根据拖拽比例自动切换透明度 */
@property (assign, nonatomic, getter=isAutomaticallyChangeAlpha) BOOL automaticallyChangeAlpha;
@end

这里有拖拽百分比和自动切换透明度,pullingPercent一般交给子类来实现,根据拖拽的比例来实现个性定制,默认是根据拖拽百分比自动切换透明度,如下:

- (void)setPullingPercent:(CGFloat)pullingPercent
{
    _pullingPercent = pullingPercent;
    
    if (self.isRefreshing) return;
    
    if (self.isAutomaticallyChangeAlpha) {
        self.alpha = pullingPercent;
    }
}

6. UILabel分类

@interface UILabel(MJRefresh)

+ (instancetype)mj_label;
- (CGFloat)mj_textWith;

@end

实现了两个方法:

@implementation UILabel(MJRefresh)
+ (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;
}
@end

创建一个label并实现计算label宽度的方法。

7. willMoveToSuperview

上面说了,MJRefreshComponent.m文件方法调用顺序是:
alloc(prepare) -> setState -> willMoveToSuperView -> layoutSubViews(placeSubviews) -> drawRect

- (void)willMoveToSuperview:(UIView *)newSuperview
{
    [super willMoveToSuperview:newSuperview];
    
    // 如果不是UIScrollView,不做任何事情
    if (newSuperview && ![newSuperview isKindOfClass:[UIScrollView class]]) return;
    
    // 旧的父控件移除监听
    [self removeObservers];
    
    if (newSuperview) { // 新的父控件
        //mj_x mj_w 的设置出现在了MJRefreshComponent的willMoveToSuperView方法中,因为这两个值始终是不会去变的。虽然可能会横竖屏切换,但是autoresizingMask的设置就解决了这个问题,MJRefresh的水平方向的布局始终是定下来了。
        // 设置宽度
        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];
    }
}

- (void)drawRect:(CGRect)rect
{
    [super drawRect:rect];
    //到底这个MJRefreshStateWillRefresh标记会有什么样的影响?drawRect是在最后才回去调用的,此时视图已经被添加到父视图了。通过这种方法,延缓了MJRefresh的刷新时间,从而保证了父视图的存在。
    if (self.state == MJRefreshStateWillRefresh) {
        // 预防view还没显示出来就调用了beginRefreshing
        self.state = MJRefreshStateRefreshing;
    }
}

这个方法在UIView的整个生命周期中是会调用两次,一次是子视图即将添加到父视图上的时候,还有一次是子视图即将从父视图移除的时候(他们的区别就是添加的时候newSuperview是有值的,移除的时候newSuperview没有值)。

可能有的小伙伴会对这个地方产生疑惑,为什么要把这些初始化操作放在这个里面?不能直接放在初始化方法中吗?
其实只要想一下MJRerfesh的服务对象就知道了,这里是判断父视图是不是scrollView以及其子类的最佳位置,放在初始化方法中没法判断父视图,放在layoutSubViews中则太晚了,而且会调用多次。

8. KVO监听

#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{}

这里监听了contentOffset和contentSize以及scrollView的pan手势的状态,监听到改变会调用相应的方法,只不过MJRefreshComponent里面单纯只是实现了这三个方法,相应的逻辑处理都在子类。

三. 下拉刷新

关于MJRefresh的继承关系,可以看MJ老师自己画的图:
结构图.png

MJRefreshComponent是不能直接做下拉刷新的,它的子类才可以。

1. MJRefreshHeader

直接继承于MJRefreshComponent
下拉刷新控件,负责监控用户下拉的状态,这个控件没添加子控件,直接使用是空白。

2. MJRefreshStateHeader

继承于MJRefreshHeader
这个控件添加了两个label,一个显示刷新时间,一个显示刷新状态,效果图如下:

MJRefreshStateHeader.png

① MJRefreshNormalHeader

继承于MJRefreshStateHeader,这个控件在两个label的基础上又添加了箭头和菊花,效果图如下:

MJRefreshNormalHeader.png

② MJRefreshGifHeader

也是继承于MJRefreshStateHeader,这个控件在两个label的基础上又添加了Gif图片,使用的时候需要子类化这个控件重写prepare方法。

- (void)prepare
{
    [super prepare];
    
    // 设置普通状态的动画图片
    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];
    
    // 设置即将刷新状态的动画图片(一松开就会刷新的状态)
    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];
    
    // 设置正在刷新状态的动画图片
    [self setImages:refreshingImages forState:MJRefreshStateRefreshing];
}

效果图如下:
MJRefreshGifHeader.png

可以看出,如果我们想自定义下拉刷新,可以根据需求自定义控件继承于MJRefreshStateHeader、MJRefreshNormalHeader、MJRefreshGifHeader。

四. 上拉刷新

关于MJRefresh的继承关系,可以看MJ老师自己画的图:
结构图.png

MJRefreshComponent是不能直接做下拉刷新的,它的子类才可以。

1. MJRefreshFooter

继承于MJRefreshComponent
上拉刷新控件的根控件,实现了创建上拉控件的方法以及抽取了上拉控件必须的方法。

2. MJRefreshAutoFooter

继承于MJRefreshFooter
会自动刷新的上拉刷新控件,不需要手动释放才刷新,不会回弹到底部,没添加子控件直接使用是空白。

MJRefreshAutoStateFooter

继承于MJRefreshAutoFooter
添加了一个label的上拉刷新,示意图如下:

MJRefreshAutoStateFooter.png

① MJRefreshAutoNormalFooter

继承于MJRefreshAutoStateFooter
在一个label的基础上又添加了菊花的上拉刷新,示意图如下:

MJRefreshAutoNormalFooter.png

② MJRefreshAutoGifFooter

继承于MJRefreshAutoStateFooter
在一个label的基础上又添加imageV动图的上拉刷新,示意图如下:

MJRefreshAutoGifFooter.png

3. MJRefreshBackFooter

继承于MJRefreshFooter
上拉需要手动释放才会刷新的上拉刷新控件,会回弹到底部,没添加子控件直接使用是空白。

MJRefreshBackStateFooter

继承于MJRefreshBackFooter
添加了一个label的上拉刷新,示意图如下:

MJRefreshBackStateFooter.png

① MJRefreshBackNormalFooter

继承于MJRefreshBackStateFooter
在一个label的基础上又添加箭头和菊花的上拉刷新,示意图如下:

MJRefreshBackNormalFooter.png

② MJRefreshBackGifFooter

也是继承于MJRefreshBackStateFooter
在一个label的基础上又添加了imageV动图的上拉刷新,示意图如下:

MJRefreshBackGifFooter.png

如果我们想自定义上拉刷新,可以根据需求自定义上拉控件继承于StateFooter、NormalFooter、GifFooter。

github地址:MJRefresh

待完整...

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。