iOS手把手教会自定义刷新控件

一:前言

记得工作中第一次用的刷新控件是svpulltorefresh,用法稍微有点麻烦,而且bug颇多,后来果断放弃,现在用的是MJRefresh,不管是用法还是bug,都比前一个好多了,但是不久前也遇到了一个致命的bug,有好些情况下会导致MJRefresh陷入一个死循环,导致不断的刷新,只能重启软件才行。MJRefresh工程比较庞大,找到了bug也很难修改,然后还是决定自己写一个,系统提供的UIRefreshControl我认为是最好的,缺点是不提供自定义UI的方法,那么我就自己基于它来自定义UI。我不是一开始就决定继承于UIRefreshControl,我同时也写了一个继承与UIView的control,两个进行对比,发现使用UIview会有很多弊端,这种弊端在一些复杂特殊的情况下一下子就暴露出来了,而且很难解决,当然,正常状态下是没什么问题的,有兴趣的同学倒是可以去试一试。本demo供大家学习和参考,如有发现bug,还请issues 我。

二: 了解 UIRefreshControl

  • 基本使用方法
//初始化一个control
UIRefreshControl *control = [[UIRefreshControl alloc] init];
//给control 添加一个刷新方法
[control addTarget:self action:@selector(refreshAction) forControlEvents:UIControlEventValueChanged];
//把control 添加到 tableView
[self.tableView addSubview:control];
  • 存在的问题

    1. 刷新时的动画是一个灰色小菊花,很多情况下不符合app的刷新动画效果
    1. 经过多次反复测试,下拉的偏移量达到130以上才会触发刷新方法,很显然这个也不符合,一般的刷新控件的高度60左右,所以下拉的偏移量达到60就可以触发刷新的方法了。
  • 自定义控件的思路

    1. 去掉默认的动画效果
    1. 自定义自己的动画效果
    1. 改变满足刷新时的条件

三:FMRefreshControl

  • 先看一下我写完的这个控件的使用方法
FMRefreshControl *control = [[FMRefreshControl alloc] initWithTargrt:self refreshAction:@selector(refreshAction)];

[self.tableView addSubview:control];

两行代码,用法比系统的还要稍微简单一点。

  • 再看一下效果
image
image

四:思路与代码

1. 关于 UIRefreshControl 的几个注意点,通过frame无法修改它的高度,修改高度目前只找到一种方法,先添加到 superViwe,再执行

[[_control.subviews objectAtIndex:0] setFrame:CGRectMake(0, 0, _control.bounds.size.width, 30)];
一开始我是想改变它的高度是否就能改变它的触发刷新的偏移量,然后我找到了这个方法可以修改它的高度,但实际上改变了高度还是无法改变触发下拉刷新的偏移量,所以我们需要自定义去触发刷新这个动作的时机。

2.手动去触发刷新动作也有几个注意点,我们是根据偏移量去触发刷新,但是仅仅靠这一个动作是不够的,还需要一个条件,那就是用户手指响应过屏幕,简单地说,先定义一个变量,如果用户触摸过屏幕,就把变量置为YES,然后再判断用户手指离开时是否达到了触发刷新的偏移量,如果两个条件都满足,就触发刷新,刷新完把变量置为NO,如果不满足,就不触发,也把变量置为NO。这样就避免了UIScrollow 因偏移量变动而导致非人为的刷新。

3. 进入代码阶段

FMRefreshControl *control = [[FMRefreshControl alloc] initWithTargrt:self refreshAction:@selector(refreshAction)];
[self.tableView addSubview:control];

初始化的时候赋一个 target 和 一个 action,当满足条件的时候,我们需要知道让谁去执行刷新方法,有这两个参数足够,当执行到第二行 addSubView的时候,我们需要在control内部实现这个方法:

- (void)willMoveToSuperview:(UIView *)newSuperview {
    [super willMoveToSuperview:newSuperview];
    
    if ([newSuperview isKindOfClass:[UIScrollView class]]) {
        self.superScrollView = (UIScrollView *)newSuperview;
        
        [self.superScrollView addObserver:self forKeyPath:@"contentOffset" options:NSKeyValueObservingOptionNew context:nil];
    }
}

这样,我们就知道当前这个control被添加到哪个父视图上了,为了安全及代码的严谨,先判断父视图是否属于
UIScrollView,如果是,就用KVO监听contentOffset属性,这样便能知道用户滑动的偏移量。

这里我定义了3种状态:

typedef NS_ENUM(NSInteger, FMRefreshState) {
    FMRefreshStateNormal = 0,     /** 普通状态 */
    FMRefreshStatePulling,        /** 释放刷新状态 */
    FMRefreshStateRefreshing,     /** 正在刷新 */
};

以及切换状态后UI的切换和方法的触发:

- (void)setCurrentStatus:(FMRefreshState)currentStatus {
    _currentStatus = currentStatus;
    switch (_currentStatus) {
        case FMRefreshStateNormal:
            NSLog(@"切换到Normal");
            [self.imageView stopAnimating];
            self.label.text = FM_Refresh_normal_title;
            [self.label sizeToFit];
            self.imageView.image = [UIImage imageNamed:@"refresh_1"];
            
            break;
        case FMRefreshStatePulling:
            NSLog(@"切换到Pulling");
            self.label.text = FM_Refresh_pulling_title;
            [self.label sizeToFit];
            self.imageView.animationImages = self.refreshingImages;
            self.imageView.animationDuration = 1.5;
            [self.imageView startAnimating];
            
            break;
        case FMRefreshStateRefreshing:
            NSLog(@"切换到Refreshing");
            self.label.text = FM_Refresh_Refreshing_title;
            [self.label sizeToFit];
            [self beginRefreshing];
            self.imageView.animationImages = self.refreshingImages;
            self.imageView.animationDuration = 1.5;
            [self.imageView startAnimating];
            [self doRefreshAction];
            
            break;
    }
}

切换到FMRefreshStateNormal 停止动画,切换到FMRefreshStatePulling 开始动画,达到这个状态,说明用户已经达到了刷新的偏移量,此时松手便可刷新,切换到FMRefreshStateRefreshing,如果此时往回滑动,小于临界值,那么状态重新切回FMRefreshStateNormal
满足刷新条件,则便可执行以下方法:

- (void)doRefreshAction
{
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
    if (self.refreshTarget && [self.refreshTarget respondsToSelector:self.refreshAction])
        [self.refreshTarget performSelector:self.refreshAction];
#pragma clang diagnostic pop
    
}

下面看最关键的KVO方法,也是这里面最复杂的逻辑处理代码:

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSString *,id> *)change context:(void *)context {
    
    //isDragging 属性是指用户手指是否在拖动
    if (self.superScrollView.isDragging && !self.isRefreshing) {
        if (!self.originalOffsetY) {
            self.originalOffsetY = -self.superScrollView.contentInset.top;
        }
        CGFloat normalPullingOffset =  self.originalOffsetY - k_FMRefresh_Height;
        if (self.currentStatus == FMRefreshStatePulling && self.superScrollView.contentOffset.y > normalPullingOffset) {
            
            self.currentStatus = FMRefreshStateNormal;
        } else if (self.currentStatus == FMRefreshStateNormal && self.superScrollView.contentOffset.y < normalPullingOffset) {
            self.currentStatus = FMRefreshStatePulling;
        }
    } else if(!self.superScrollView.isDragging){
        
        if (self.currentStatus == FMRefreshStatePulling) {
            
            self.currentStatus = FMRefreshStateRefreshing;
        }
    }
 //拖动的偏移量,转换成正数
    CGFloat pullDistance = -self.frame.origin.y;
    self.backgroundView.frame = CGRectMake(0, 0, k_FMRefresh_Width, pullDistance);
    CGFloat totalWidth = 35 + 20 + self.label.bounds.size.width;
    CGFloat imageViewX = (k_FMRefresh_Width - totalWidth)/2;
    
    self.imageView.frame = CGRectMake(imageViewX,  -k_FMRefresh_Height+pullDistance+(k_FMRefresh_Height - self.imageView.bounds.size.height)/2, self.imageView.frame.size.width, self.imageView.frame.size.height);
    self.label.frame = CGRectMake(imageViewX + 35 + 20, -k_FMRefresh_Height + pullDistance + (k_FMRefresh_Height - self.label.bounds.size.height)/2, self.label.frame.size.width, self.label.frame.size.height);   
}

这里最重要的就是处理两点:1. 根据偏移量和用户手指的拖动来切换状态,2. control上面的子视图需要我们根据偏移量来实时更新。

还有一种情况,上面也提到过,用户先滑动到FMRefreshStatePulling状态,然后又往回滑动,此时的偏移量在0-FMRefreshStatePulling状态的偏移量之间,此时调用自身的 endRefreshing偏移量不会复原,还需要我们自己处理,看了几个老外写的自定义刷新控件,他们都没修复这个bug。他们也没封装,全部代码写在了控制器里,什么都没有改变,只是实现了一个动画效果,还多了个bug,动画效果倒是不错的。有兴趣的可以参考一番:
https://www.jackrabbitmobile.com/app-development/ios-custom-pull-to-refresh-contro/
https://possiblemobile.com/2014/05/ios-custom-pull-to-refresh/

- (void)endRefreshing {
    if (self.currentStatus != FMRefreshStateRefreshing) {
        return;
    }
    self.currentStatus = FMRefreshStateNormal;
    [super endRefreshing];
    
    //在执行刷新的状态中,用户手动拖动到 nornal 状态的 offset,[super endRefreshing] 无法回到初始位置,所以手动设置
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        if(self.superScrollView.contentOffset.y >= self.originalOffsetY - k_FMRefresh_Height && self.superScrollView.contentOffset.y <= self.originalOffsetY) {
            CGPoint offset = self.superScrollView.contentOffset;
            offset.y = self.originalOffsetY;
            [self.superScrollView setContentOffset:offset animated:YES];
        }
    });

}

最后还有一点不要忘记 dealloc移除监听:

- (void)dealloc {
    [self.superScrollView removeObserver:self forKeyPath:@"contentOffset"];
}

整篇文章从上至下是按照整个完整的思路写下来的,先是提出遇到的问题以及难点,然后最后的代码和思路也是由外至内一路写下来,希望方便大家阅读。这是上篇,下拉刷新的,还有下篇,上拉加载,过两天写,demo中已经有了,不过就是还没优化。

domo地址:https://github.com/suifengqjn/FMRefreshControl

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

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,409评论 25 707
  • 曾记得导演候孝贤说过,所有的时光都被辜负被浪费后,才能从记忆里将某一段拎起,拍拍上面沉积的灰尘,感叹它是最好的时光...
    丁翎阅读 152评论 0 0
  • 断断续续看完了这部长达四个小时的电影,思绪万千。这部电影融合了色情,暴力,纯爱,宗教等多种元素,让漫长的四小...
    好久没看见雪了阅读 820评论 2 1
  • 第一站:慕尼黑·Munich 12.04.2017.柏林-慕尼黑(飞机)47€/约353¥ 75min(一般提前1...
    琪仔小丸子阅读 452评论 0 1
  • 践行18天,这些年我管理过的时间 这些年都没刻意去管理过时间!曾经青春年少,肆意挥霍时间,也曾一瞬间的长大,发现不...
    徐殊文阅读 202评论 0 0