MJRefresh源码阅读2——核心类MJRefreshHeader

前言

MJRefresh源码阅读1——结构梳理中我们已经说了MJRefreshHeader是整个控件的核心类,它完成了一个刷新控件应该有的所有逻辑和UI显示,它已经是个成型的,较简单的,麻雀虽小五脏俱全的刷新头header

说到一个成型的刷新头,MJRefresh它的核心逻辑应该是:当该header添加到scrollView上后,作者以scrollView往下拉动到的不同偏移量,来相应地给header定义了几种状态statescrollView的偏移量contentOffset引起headerstate变化,而不同state下要设置各自的显示样式。

我们直接来看MJRefreshHeader.m文件。属性和方法概览如下图所示,因为该类代码较长,我们分段来分析。

屏幕快照 2017-01-04 下午1.47.05.png

可以看到在.m文件的extension中定义了几个属性:显示上次刷新时间的标签updatedTimeLabel;显示状态对应文字的标签stateLabelNSDate类型的,表示上次刷新时间的updatedTime;以及一个代表所有状态对应文字的字典stateTitles

然后.m文件一开始便实现了其对应的getter方法,在getter方法里直接将其addSubView:header了。

@interface MJRefreshHeader()
/** 显示上次刷新时间的标签 */
@property (weak, nonatomic) UILabel *updatedTimeLabel;
/** 上次刷新时间 */
@property (strong, nonatomic) NSDate *updatedTime;
/** 显示状态文字的标签 */
@property (weak, nonatomic) UILabel *stateLabel;
/** 所有状态对应的文字 */
@property (strong, nonatomic) NSMutableDictionary *stateTitles;
@end

@implementation MJRefreshHeader
#pragma mark - 懒加载
- (NSMutableDictionary *)stateTitles
{
    if (!_stateTitles) {
        self.stateTitles = [NSMutableDictionary dictionary];
    }
    return _stateTitles;
}

- (UILabel *)stateLabel
{
    if (!_stateLabel) {
        UILabel *stateLabel = [[UILabel alloc] init];
        stateLabel.backgroundColor = [UIColor clearColor];
        stateLabel.textAlignment = NSTextAlignmentCenter;
        [self addSubview:_stateLabel = stateLabel];
    }
    return _stateLabel;
}

- (UILabel *)updatedTimeLabel
{
    if (!_updatedTimeLabel) {
        UILabel *updatedTimeLabel = [[UILabel alloc] init];
        updatedTimeLabel.backgroundColor = [UIColor clearColor];
        updatedTimeLabel.textAlignment = NSTextAlignmentCenter;
        [self addSubview:_updatedTimeLabel = updatedTimeLabel];
    }
    return _updatedTimeLabel;
}

然后下来是几个“初始化方法”,“准备方法”。在它们几个方法中基本都是设置一些默认的属性。

#pragma mark - 初始化方法
- (instancetype)initWithFrame:(CGRect)frame {
    if (self = [super initWithFrame:frame]) {
        // 设置默认的dateKey
        self.dateKey = MJRefreshHeaderUpdatedTimeKey;
        
        // 设置为默认状态
        self.state = MJRefreshHeaderStateIdle;
        
        // 初始化文字
        [self setTitle:MJRefreshHeaderStateIdleText forState:MJRefreshHeaderStateIdle];
        [self setTitle:MJRefreshHeaderStatePullingText forState:MJRefreshHeaderStatePulling];
        [self setTitle:MJRefreshHeaderStateRefreshingText forState:MJRefreshHeaderStateRefreshing];
    }
    return self;
}

- (void)willMoveToSuperview:(UIView *)newSuperview
{
    [super willMoveToSuperview:newSuperview];
    
    if (newSuperview) {
        self.mj_h = MJRefreshHeaderHeight;
    }
}

- (void)drawRect:(CGRect)rect
{
    if (self.state == MJRefreshHeaderStateWillRefresh) {
        self.state = MJRefreshHeaderStateRefreshing;
    }
}

- (void)layoutSubviews
{
    [super layoutSubviews];
    
    // 设置自己的位置
    self.mj_y = - self.mj_h;
    
    // 2个标签都隐藏
    if (self.stateHidden && self.updatedTimeHidden) return;
    
    if (self.updatedTimeHidden) { // 显示状态
        _stateLabel.frame = self.bounds;
    } else if (self.stateHidden) { // 显示时间
        self.updatedTimeLabel.frame = self.bounds;
    } else { // 都显示
        CGFloat stateH = self.mj_h * 0.55;
        CGFloat stateW = self.mj_w;
        // 1.状态标签
        _stateLabel.frame = CGRectMake(0, 0, stateW, stateH);
        
        // 2.时间标签
        CGFloat updatedTimeY = stateH;
        CGFloat updatedTimeH = self.mj_h - stateH;
        CGFloat updatedTimeW = stateW;
        self.updatedTimeLabel.frame = CGRectMake(0, updatedTimeY, updatedTimeW, updatedTimeH);
    }
}

下面两个方法是对header里上次刷新时间的处理。一个dateKey对应一个updatedTime,每个页面在刷新过程中只要变为refreshing状态,便会存储该时刻的时间,是存储在userDefault中的,以dateKey为键,以updatedTime为值。

- (void)setDateKey:(NSString *)dateKey
{
    _dateKey = dateKey ? dateKey : MJRefreshHeaderUpdatedTimeKey;
    
    self.updatedTime = [[NSUserDefaults standardUserDefaults] objectForKey:_dateKey];
}

#pragma mark 设置最后的更新时间
- (void)setUpdatedTime:(NSDate *)updatedTime
{
    _updatedTime = updatedTime;
    
    if (updatedTime) {
        [[NSUserDefaults standardUserDefaults] setObject:updatedTime forKey:self.dateKey];
        [[NSUserDefaults standardUserDefaults] synchronize];
    }
    
    if (self.updatedTimeTitle) {
        self.updatedTimeLabel.text = self.updatedTimeTitle(updatedTime);
        return;
    }
    
    if (updatedTime) {
        // 1.获得年月日
        NSCalendar *calendar = [NSCalendar currentCalendar];
        NSUInteger unitFlags = NSCalendarUnitYear| NSCalendarUnitMonth | NSCalendarUnitDay |NSCalendarUnitHour |NSCalendarUnitMinute;
        NSDateComponents *cmp1 = [calendar components:unitFlags fromDate:updatedTime];
        NSDateComponents *cmp2 = [calendar components:unitFlags fromDate:[NSDate date]];
        
        // 2.格式化日期
        NSDateFormatter *formatter = [[NSDateFormatter alloc] init];
        if ([cmp1 day] == [cmp2 day]) { // 今天
            formatter.dateFormat = @"今天 HH:mm";
        } else if ([cmp1 year] == [cmp2 year]) { // 今年
            formatter.dateFormat = @"MM-dd HH:mm";
        } else {
            formatter.dateFormat = @"yyyy-MM-dd HH:mm";
        }
        NSString *time = [formatter stringFromDate:updatedTime];
        
        // 3.显示日期
        self.updatedTimeLabel.text = [NSString stringWithFormat:@"最后更新:%@", time];
    } else {
        self.updatedTimeLabel.text = @"最后更新:无记录";
    }
}

下面就到了最核心的地方了。我们在上一篇已经说了在MJRefreshComponent类中已经以KVO的方式给scrollViewcontentOffset属性添加了监听。只要contentOffset属性发生变化便会执行下面的回调方法。代码中注释得很详细了,就不赘述了。
需要说明的是一开始我不明白_scrollViewOriginalInset这个变量是什么意思——它就是代表scrollView的原始contentInset值。它不应当是0吗?其实有时不一定是0。

#pragma mark KVO属性监听
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
{
    // 遇到这些情况就直接返回
    if (!self.userInteractionEnabled || self.alpha <= 0.01 || self.hidden || self.state == MJRefreshHeaderStateRefreshing) return;
    
    // 根据contentOffset调整state
    if ([keyPath isEqualToString:MJRefreshContentOffset]) {
        [self adjustStateWithContentOffset];
    }
}

#pragma mark 根据contentOffset调整state
- (void)adjustStateWithContentOffset
{
    if (self.state != MJRefreshHeaderStateRefreshing) {
        // 在刷新过程中,跳转到下一个控制器时,contentInset可能会变
        _scrollViewOriginalInset = _scrollView.contentInset;
    }
    
    // 在刷新的 refreshing 状态,动态设置 content inset
    if (self.state == MJRefreshHeaderStateRefreshing ) {
        if(_scrollView.contentOffset.y >= -_scrollViewOriginalInset.top ) {
            _scrollView.mj_insetT = _scrollViewOriginalInset.top;
        } else {
            _scrollView.mj_insetT = MIN(_scrollViewOriginalInset.top + self.mj_h,
                                        _scrollViewOriginalInset.top - _scrollView.contentOffset.y);
        }
        return;
    }
    
    // 当前的contentOffset
    CGFloat offsetY = _scrollView.mj_offsetY;
    // 头部控件刚好出现的offsetY
    CGFloat happenOffsetY = - _scrollViewOriginalInset.top;
    
    // 如果是向上滚动到看不见头部控件,直接返回
    if (offsetY >= happenOffsetY) return;
    
    // 普通 和 即将刷新 的临界点
    CGFloat normal2pullingOffsetY = happenOffsetY - self.mj_h;
    if (_scrollView.isDragging)
    {
        self.pullingPercent = (happenOffsetY - offsetY) / self.mj_h;
        // 刚开始往下拉,拉到偏移量大于54时,状态变为pulling
        if (self.state == MJRefreshHeaderStateIdle && offsetY < normal2pullingOffsetY) {
            // #转为即将刷新状态
            self.state = MJRefreshHeaderStatePulling;
            // #当往下拉超过54后,往回推,推到54以上时状态由pulling变为idle
        } else if (self.state == MJRefreshHeaderStatePulling && offsetY >= normal2pullingOffsetY) {
            // 转为普通状态
            self.state = MJRefreshHeaderStateIdle;
        }
    }
    // #以下为松开手后
    // 若松开手时此刻的状态是pulling,说明已往下拉过54的偏移量,则将其变为refreshing状态
    else if (self.state == MJRefreshHeaderStatePulling) {// 即将刷新 && 手松开
        self.pullingPercent = 1.0;
        // 开始刷新
        self.state = MJRefreshHeaderStateRefreshing;
    } else {
        self.pullingPercent = (happenOffsetY - offsetY) / self.mj_h;
    }
}

接下来就是几个提供给外部控制刷新状态的方法了。除了控件自身可以通过contentOffset来切换状态外,外部调用者也可以调用这几个方法来切换header的状态。包括最后一个方法判断header是否正在刷新,即判断它当前的状态是否为MJRefreshHeaderStateRefreshing

- (void)beginRefreshing
{
    if (self.window) {
        self.state = MJRefreshHeaderStateRefreshing;
    } else {
        self.state = MJRefreshHeaderStateWillRefresh;
        // 刷新(预防从另一个控制器回到这个控制器的情况,回来要重新刷新一下)
        [self setNeedsDisplay];
    }
}

- (void)endRefreshing
{
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.05 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        self.state = MJRefreshHeaderStateIdle;
    });
}

- (BOOL)isRefreshing
{
    return self.state == MJRefreshHeaderStateRefreshing;
}

接下来也是一个非常核心的方法,重写了statesetter方法。我们看看其在切换state时都做了什么事。

第一个case的意思是,外部调用了endRefreshing方法,停止了刷新,状态由refreshing变为idle。此时首先记录并存储了当前的存储时间,然后将header从顶部退出:其实是将scrollViewcontenInset由原来的54设置为0。
第二个case的意思是,开始刷新了,在开始refreshing状态时,首先将headercontentOffsetcontentInset的值均设置为54。然后有block回调,就调用执行block回调,有SEL回调,就调用执行SEL回调。

- (void)setState:(MJRefreshHeaderState)state
{
    if (_state == state) return;
    
    // 旧状态
    MJRefreshHeaderState oldState = _state;
    
    // 赋值
    _state = state;
    
    // 设置状态文字
    _stateLabel.text = _stateTitles[@(state)];
    
    switch (state) {
        case MJRefreshHeaderStateIdle: {
            if (oldState == MJRefreshHeaderStateRefreshing) { // #当外部调用endRefreshing后,由refreshing状态变为idle状态
                // 保存刷新时间
                self.updatedTime = [NSDate date];
                
                // 恢复inset和offset
                [UIView animateWithDuration:MJRefreshSlowAnimationDuration delay:0.0 options:UIViewAnimationOptionAllowUserInteraction|UIViewAnimationOptionBeginFromCurrentState animations:^{
                    // 修复top值不断累加
                    _scrollView.mj_insetT -= self.mj_h; // 刷新的header视图从顶部退出:其实是将scrollView的contenInset由原来的54设置为0
                } completion:nil];
            }
            break;
        }
            
        case MJRefreshHeaderStateRefreshing: {
            [UIView animateWithDuration:MJRefreshFastAnimationDuration delay:0.0 options:UIViewAnimationOptionAllowUserInteraction|UIViewAnimationOptionBeginFromCurrentState animations:^{
                // 增加滚动区域
                CGFloat top = _scrollViewOriginalInset.top + self.mj_h;
                _scrollView.mj_insetT = top;
                
                // 设置滚动位置
                _scrollView.mj_offsetY = - top;
            } completion:^(BOOL finished) {
                // 回调
                if (self.refreshingBlock) {
                    self.refreshingBlock();
                }
                
                if ([self.refreshingTarget respondsToSelector:self.refreshingAction]) {
                    msgSend(msgTarget(self.refreshingTarget), self.refreshingAction, self);
                }
            }];
            break;
        }
            
        default:
            break;
    }
}

在该类的最后是下面几个功能方法。前两个是重写的父类的方法,后两个是重写的本类的两个属性的setter方法,用来控制stateLabelupdatedTimeLabel的可见性,因为这两个可不可见会影响UI布局,所以在俩方法内都调用了setNeedsLayout方法表示需要重绘,会再次执行一遍layoutSubviews方法,重新调整一遍布局。

- (void)setTextColor:(UIColor *)textColor
{
    [super setTextColor:textColor];
    
    self.updatedTimeLabel.textColor = textColor;
    self.stateLabel.textColor = textColor;
}

- (void)setFont:(UIFont *)font
{
    [super setFont:font];
    
    self.updatedTimeLabel.font = font;
    self.stateLabel.font = font;
}

- (void)setStateHidden:(BOOL)stateHidden
{
    _stateHidden = stateHidden;
    
    self.stateLabel.hidden = stateHidden;
    [self setNeedsLayout];
}

- (void)setUpdatedTimeHidden:(BOOL)updatedTimeHidden
{
    _updatedTimeHidden = updatedTimeHidden;
    
    self.updatedTimeLabel.hidden = updatedTimeHidden;
    [self setNeedsLayout];
}

结尾

至此,MJRefresh源码的逻辑基本梳理清楚了,能看清它是怎么实现的了。后面还会写一篇,整理一下它里面出现的一些值得掌握的知识点。

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

推荐阅读更多精彩内容