自定义MJRefresh:“什么值得买”的下拉刷新实现

写在前面

“什么值得买”是我这种剁手族常用的软件,最近发现它的下拉刷新做得挺好的,而且也算是一种经常见到的样式,正好前几天刚好分析完了MJRefresh,趁热打铁,这次就来尝试实现一下它的下拉刷新吧。

一、总体构成

原厂效果图

从上图可以看出,RefreshView主要是两部分组成:

  • 位于上方的Label
  • 位于下方的ImageView

Label就不多介绍了,我们来重点看一下Image部分。

  • 下拉过程中,Circle可以随着我们下拉的位移量改变
  • 刷新过程中,缺了一角的Circle会围绕“值”旋转
  • 刷新完毕后,动画结束

实现难度不大,下面我们就开始动手吧。

二、刷新部分

最终实现效果

最后的效果大概就是这个样子的,还算合格,我们来详细分析下。

(一)资源

提取ipa包内图片资源的方法有很多,而我这人比较懒,所以喜欢直接用工具,这里推荐给大家一款我一直用的:iOS-Images-Extractor,国内的某Coder写的,很好用,分享给大家,好用的话别忘了点个星,是给作者最大的鼓舞。

iOS-Images-Extractor使用界面

使用方法很简单,把ipa包拖进去,点击start等待分析完成,之后点击Output Dir就会自动跳转到输出目录。


OK,工具介绍完,我已经把图片找出来了,一共俩:

看到这俩角色,就明了了,一开始我以为缺了一块的Circle是用ShapeLayer画的,原来是美工做的,那就直接用吧,省事。

(二)动画实现

图片"zhi"在下,"circle"在上,然后对circle做旋转动画就OK了。

- (void)viewDidLoad {
    [super viewDidLoad];
    [self.logoView addSubview:self.circleView];
    [self.view addSubview:self.logoView];
}

- (void)viewDidLayoutSubviews{
    self.logoView.center = self.view.center;
    self.logoView.bounds = CGRectMake(0, 0, 30, 30);
    self.circleView.frame = self.logoView.bounds;
}

- (void)viewDidAppear:(BOOL)animated{
    [self.circleView.layer addAnimation:[self getTransformAnimation] forKey:nil];
}

-(CABasicAnimation *)getTransformAnimation{
    CABasicAnimation *animation   = [CABasicAnimation animationWithKeyPath:@"transform.rotation"]; //指定对transform.rotation属性做动画
    animation.duration            = 2.0f; //设定动画持续时间
    animation.byValue             = @(M_PI*2); //设定旋转角度,单位是弧度
    animation.fillMode            = kCAFillModeForwards;//设定动画结束后,不恢复初始状态之设置一
    animation.repeatCount         = 1000;//设定动画执行次数
    animation.removedOnCompletion = NO;//设定动画结束后,不恢复初始状态之设置二
    return animation;
}

- (UIImageView *)logoView{
    if (!_logoView) {
        _logoView = [[UIImageView alloc] init];
        _logoView.image = [UIImage imageNamed:@"zhi"];
    }
    return _logoView;
}

- (UIImageView *)circleView{
    if (!_circleView) {
        _circleView = [[UIImageView alloc] init];
        _circleView.image = [UIImage imageNamed:@"circle"];
    }
    return _circleView;
}

很简单,主要就是动画部分,如果对动画不熟悉的童鞋,推荐ios核心动画高级技巧

三、下拉部分

这部分,主要是要实现Circle随我们手势改变自身完成度,先上效果图:

实现效果图

(一) 用ShapeLayer画个圆:

这里的Circle部分,我们用CAShapeLayer来做:

CAShapeLayer是一个通过矢量图形而不是bitmap来绘制的图层子类。你指定诸如颜色和线宽等属性,用CGPath来定义想要绘制的图形,最后CAShapeLayer就自动渲染出来了。

形象点来说,就是你给CAShapeLayer指定脚本(Path),并设定好各属性(Color,Width)之后,CAShapeLayer就自动完成了。

-(CAShapeLayer *)getShape{
    UIBezierPath *path       = [UIBezierPath bezierPathWithOvalInRect:self.logoView.bounds];//先写剧本

    CAShapeLayer *shapeLayer = [CAShapeLayer layer];
    shapeLayer.path          = path.CGPath;//安排剧本
    shapeLayer.fillColor     = [UIColor clearColor].CGColor;//填充色要为透明,不然会遮挡下面的图层
    shapeLayer.strokeColor   = [UIColor redColor].CGColor;
    shapeLayer.lineWidth     = 1.0;
    shapeLayer.frame         = self.logoView.bounds;
    return shapeLayer;
}

- (void)viewDidAppear:(BOOL)animated{
    [self.logoView.layer addSublayer:[self getShape]]; //将ShapeLayer图层增加到logoView上
}
画个圆

(二)控制ShapeLayer的绘制进度

圆画完了,下面是和Slider.value关联,让我们能控制圆的绘制进度。
关键属性:strokeStartstrokeEnd

  • strokeStart:从哪开始绘制
  • strokeEnd:在哪结束绘制

我们设定我们的圆起始点为:

    shapeLayer.strokeStart   = 0;
    shapeLayer.strokeEnd     = 0.9;
stroke起始点

可以出,stroke属性的特点:

  • 单位是百分比
  • 0点在Layer右侧中心
  • 顺时针绘制

有了这个属性,我们就可以很方便的实现我们的目标了。
我们把strokeEnd的初始值设为0,再与我们的Slider.value挂钩就好了。

完整代码:

- (void)viewDidAppear:(BOOL)animated{
    [self.logoView.layer addSublayer:self.circleLayer];
}

- (CALayer *)circleLayer{
    if (!_circleLayer) {
        _circleLayer = [self getShape];
    }
    return _circleLayer;
}

- (CAShapeLayer *)getShape{
    UIBezierPath *path       = [UIBezierPath bezierPathWithOvalInRect:self.logoView.bounds];
    CAShapeLayer *shapeLayer = [CAShapeLayer layer];
    shapeLayer.fillColor     = [UIColor clearColor].CGColor;
    shapeLayer.strokeColor   = [UIColor redColor].CGColor;
    shapeLayer.lineWidth     = 1.0;
    shapeLayer.path          = path.CGPath;
    shapeLayer.frame         = self.logoView.bounds;
    shapeLayer.strokeEnd     = 0;
    return shapeLayer;
}

- (IBAction)didSlide:(UISlider *)sender {
    self.circleLayer.strokeEnd = sender.value;
}

四、自定义MJRefresh

经过上面两个步骤,我们已经实现了下拉刷新的核心视图动画,接下来该自定义MJRefresh了。
老规矩,先上完成图:


完成效果图

自定义MJRefreshHeader,需要继承自MJRefreshHeader,看过我之前文章的小伙伴一定很熟悉了。
不熟悉也不要紧,不过就有点死记硬背的感觉了。

(一)布局

#pragma mark - Const
CGRect kZZZLogoViewBounds = {0,0,25,25};
#pragma mark 在这里做一些初始化配置(比如添加子控件)
- (void)prepare
{
    [super prepare];
    [self.logoView addSubview:self.circleView];
    [self.logoView.layer addSublayer:self.circleLayer];

    [self addSubview:self.logoView];
}

#pragma mark 在这里设置子控件的位置和尺寸

- (void)placeSubviews
{
    [super placeSubviews];
    self.logoView.center = CGPointMake(self.mj_w/2.0, self.mj_h/2.0 + 10.0);// +10是为了logoView在中心点往下一点的位置,方便观看
    self.logoView.bounds = kZZZLogoViewBounds;
    self.circleView.frame = self.logoView.bounds;
}

#pragma mark - setter & getter

- (UIImageView *)logoView{
    if (!_logoView) {
        _logoView = [[UIImageView alloc] init];
        _logoView.image = [UIImage imageNamed:@"zhi"];
    }
    return _logoView;
}

- (UIImageView *)circleView{
    if (!_circleView) {
        _circleView = [[UIImageView alloc] init];
        _circleView.image = [UIImage imageNamed:@"circle"];
        _circleLayer.hidden = YES; //刷新时候的图片,开始的时候不需要显示出来
    }
    return _circleView;
}

- (CAShapeLayer *)circleLayer{
    if (!_circleLayer) {
        _circleLayer = [self creatCircleShapeLayerWithBounds:kZZZLogoViewBounds];//跟上面的getShapeLayer方法一样,不过这里我稍微改写了原函数,减少依赖
    }
    return _circleLayer;
}

有几点需要说明的:

  • MJRefresh默认高度是54,如需修改,放在prepare文件中即可:self.mj_h = **
  • prepare方法中,不能放布局相关的内容,因为调用prepare是在视图初始化的时候,这时候MJRefresh还没有加入到View Hierarchy
  • placeSubViews方法中,注意MJRefreshView的Frame.origin = (0, -self.mj_h),所以调整Y值的时候注意正负。


    布局

自定义的时候,慢慢来,出了BUG一般是Frame没设置好,多利用调试工具。

(二)设置动态响应

我们只需要做两件事情:

(1)将下拉位移量与我们的strokeEnd属性关联

关联这件事情,MJRefresh已经帮我们处理了前半部分,我们只需要在相应方法里写个等式就可以了。

(2) 处理状态

  • Idle :我们要设置各个组件是否隐藏
  • Pulling: 不需要处理
  • Refreshing:把CircleLayer隐藏,把CircleView显示并做旋转动画

注意的是,我们的需要在endRefreshing方法中,手动移除动画(因为我们在动画定义部分为了动画的流畅性,设置了animation.removedOnCompletion = NO),不然CircleView上的动画会一直运行。

#pragma mark 监听控件的刷新状态
- (void)setState:(MJRefreshState)state
{
    MJRefreshCheckState;
    
    switch (state) {
        case MJRefreshStateIdle:
            self.circleView.hidden = YES;
            self.circleLayer.hidden = NO;
            break;
        case MJRefreshStatePulling:
            break;
        case MJRefreshStateRefreshing:
            self.circleView.hidden = NO;
            self.circleLayer.hidden = YES;
            [self.circleView.layer addAnimation:[self creatTransformAnimation] forKey:nil];
            break;
        default:
            break;
    }
}

- (void)setPullingPercent:(CGFloat)pullingPercent
{
        self.circleLayer.strokeEnd = pullingPercent;
}

- (void)endRefreshing{


    [self.circleView.layer removeAllAnimations];
    [super endRefreshing];
}

来看一下运行结果:

对比原版,貌似有几点问题:

  • Refreshing状态的时候,CircleLayer的消失做了一个动画
  • Refreshing结束的时候,CirCleLayer因为和PullingPersent的关联,strokeEnd直接设为了0

有问题,就解决问题呗。

第一个问题
self.circleLayer.hidden = YES;

问题出在这行代码上。
这涉及到了CoreAnimation的隐式动画部分,说白了,你对Layer做的属性修改,会触发系统的隐藏动画,所以我们取消系统隐藏动画就好了。取消方法如下:

[CATransaction begin];
[CATransaction setDisableActions:YES];
self.circleLayer.hidden = YES;
[CATransaction commit];
运行结果

好的,这个问题已经不是问题了。

第二个问题

原厂的动画是,刷新完成之后,CircleLayer要保持StrokeEnd = 1.0的状态。

也就是说,需要个参数,能区分进入Idle状态之前是否刷新过,那我们就加个参数呗。

改动部分代码如下:


- (void)prepare
{
    [super prepare];
    [self.logoView addSubview:self.circleView];
    [self.logoView.layer addSublayer:self.circleLayer];
    [self addSubview:self.logoView];
    self.hasRefreshed = NO;//初始化的时候,肯定是没有刷新过的
}

#pragma mark 监听控件的刷新状态
- (void)setState:(MJRefreshState)state
{
    MJRefreshCheckState;
    
    switch (state) {
        case MJRefreshStateIdle:
            self.circleView.hidden = YES;
            self.circleLayer.hidden = NO;
            break;
        case MJRefreshStatePulling:
            break;
        case MJRefreshStateRefreshing:
            
            [CATransaction begin];
            [CATransaction setDisableActions:YES];
            self.circleLayer.hidden = YES;
            [CATransaction commit];
        
            self.circleView.hidden = NO;
            [self.circleView.layer addAnimation:[self creatTransformAnimation] forKey:nil];

            self.hasRefreshed = YES;//刷新过了
            break;
        default:
            break;
    }
}

#pragma mark 监听拖拽比例(控件被拖出来的比例)

- (void)setPullingPercent:(CGFloat)pullingPercent
{
    if (self.hasRefreshed) {//刷新返回的时候,strokeEnd = 1.0 
        [CATransaction begin];
        [CATransaction setDisableActions:YES];
        self.circleLayer.strokeEnd = 1.0;
        [CATransaction commit];
        self.hasRefreshed = NO;//重置状态为未刷新
    }else{
        self.circleLayer.strokeEnd = pullingPercent;
    }
}

搞定。

总结

MJRefresh给我们提供了很好的底层实现,我们可以在它的基础上,进行丰富的自定义,基本都能满足自己的需求。
哪怕是实在满足不了你了,也可以借鉴MJRefresh的整体思路,自己写一个简单的框架。

我在分析完MJRefresh的技术细节之后,不再感觉自己面对的是一个黑匣子,修改起来是相当地轻松。
所以,读源码果然是提高自己技术水平的有效手段(就是有点累)。

至此,MJRefresh的旅程就算结束了。

希望大家以后都能做出独具个性的刷新控件。

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

推荐阅读更多精彩内容

  • 发现 关注 消息 iOS 第三方库、插件、知名博客总结 作者大灰狼的小绵羊哥哥关注 2017.06.26 09:4...
    肇东周阅读 11,952评论 4 60
  • 作者:【美】阿西莫格鲁【美】罗宾逊 译者:李增钢 出版社:湖南科学技术出版社 作者介绍:阿西莫格鲁是麻省理工大学经...
    哈皮波阅读 1,058评论 1 3
  • 01 我给了自己半个小时平静的时间,做了个与时间赛跑的重要决定,然后骑上小黄蜂飞快地奔向第一个站点,那里是我战斗的...
    迷小希阅读 244评论 3 5
  • 千里共良宵,良宵一刻值千金。 呵呵,其实此刻累得跟个孙子是的。 每到到午夜时分夜深人静的时候,开车巡游四处揽客的我...
    乘风破浪的Mrzhao阅读 176评论 0 0
  • 风比阳光还要温暖 一点一滴蹭化了冬日的坚冰 大地褪去了雪白的伪装 星星点点间都显现出生命的痕迹 我静静地望着天边的...
    广意_阅读 214评论 0 2