iOS动画篇:下拉刷新动画

BOSS直聘APP的下拉刷新动画蛮有趣的,我们来尝试实现一下。

先来看看最终效果:
SURefresh_Finally.gif
关于实现思路:

实现思路这东西,并不是一成不变的,每个人心中都有自己喜欢的思想和套路,这里仅分享下我的思路,力图起到抛砖引玉的作用,深入思考,也许你会有更好的方法和思路。

动画拆分

再复杂的动画都可以拆分成许多简单的动画组合起来,这个动画大概可以分成两个主体,我把它分别录制出来给大家看看
第一个,下拉过程中的动画


下拉过程中的动画.gif

第一个动画又可以拆分为4个大阶段,对应着4个点之间的动画过程:


4个点.png

每个大阶段又可以拆分为2个小阶段(以第一个和第二个点为例):
1)A点到B点之间的动画:B点不出现,以A点为起点,从A点一直“伸”到B点
2)B点到A点之间的动画:B点出现,以B点为终点,从A点一直“缩”到B点

综上,第一个动画可以拆分为8个阶段:(简书的图片怎样才能横着排列?这竖着也太占版面了CACACA)


step1.png

step2.png

step3.png

step4.png

step5.png

step6.png

step7.png

step8.png

step9.png

step10.png

step11.png

step12.png

step13.png

step14.png

第二个,进入刷新状态的动画


进入刷新状态的动画.gif

第二个动画又可以拆分为两个单独动画(旋转+移动)的组合:
1)整体旋转动画:整体不断重复360度旋转
2)点反复移动动画:4个点在旋转360的周期内进行(内->外->内->外)的移动

动画实现方式

了解了动画的过程,我们来选择动画的实现方式,由于这里仅需要画圆形,我们选择CAShapeLayer来实现。

CAShapeLayer的简介:

CAShapeLayer顾名思义,就是代表一个形状(Shape)的Layer,它是CALayer的子类。
CAShapeLayer初始化需要指定Frame,但它的形状是由path属性来决定,且必须指定path,不然会没有形状。

CAShapeLayer的重要属性:

1、lineWidth 渲染线的宽度
2、lineCap、lineJoin 渲染线两端和转角的样式
3、fillColor、strokeColor 填充、描边的渲染颜色
4、path 指定的绘图路径,path不完整会自动封闭区域
5、strokeStart、strokeEnd 绘制path的起始和结束的百分比

CAShapeLayer的动画特点:

1、CAShapeLayer跟CALayer一样自带动画效果
2、CAShapeLayer的动画效果仅限沿路径变化,不支持填充区域的动画效果

动画实现

我们自定义一个RefreshHeaderView,并通过分类将其和scrollView关联,当进行下拉操作的时候,headerView进行相应的动画。

1)固定位置的4个点

对应4个Layer,Layer的路径是圆形,填充颜色和路径颜色一致

    CGPoint topPoint = CGPointMake(centerLine, radius);
    self.TopPointLayer = [self layerWithPoint:topPoint color:topPointColor];
    self.TopPointLayer.hidden = NO;
    self.TopPointLayer.opacity = 0.f;
    [self.layer addSublayer:self.TopPointLayer];
    
    CGPoint leftPoint = CGPointMake(radius, centerLine);
    self.LeftPointLayer = [self layerWithPoint:leftPoint color:leftPointColor];
    [self.layer addSublayer:self.LeftPointLayer];
    
    CGPoint bottomPoint = CGPointMake(centerLine, SURefreshHeaderHeight - radius);
    self.BottomPointLayer = [self layerWithPoint:bottomPoint color:bottomPointColor];
    [self.layer addSublayer:self.BottomPointLayer];
    
    CGPoint rightPoint = CGPointMake(SURefreshHeaderHeight - radius, centerLine);
    self.rightPointLayer = [self layerWithPoint:rightPoint color:rightPointColor];
    [self.layer addSublayer:self.rightPointLayer];
- (CAShapeLayer *)layerWithPoint:(CGPoint)center color:(CGColorRef)color {
    CAShapeLayer * layer = [CAShapeLayer layer];
    layer.frame = CGRectMake(center.x - SURefreshPointRadius, center.y - SURefreshPointRadius, SURefreshPointRadius * 2, SURefreshPointRadius * 2);
    layer.fillColor = color;
    layer.path = [self pointPath];
    layer.hidden = YES;
    return layer;
}

- (CGPathRef)pointPath {
    return [UIBezierPath bezierPathWithArcCenter:CGPointMake(SURefreshPointRadius, SURefreshPointRadius) radius:SURefreshPointRadius startAngle:0 endAngle:M_PI * 2 clockwise:YES].CGPath;
}
2)4个点的连接介质

对应一个Layer,Layer的路径是由4段直线拼接而成,直线的直径和圆形的直接一致,初始的渲染结束位置为0。
8个阶段的动画,可以看成是Layer的渲染开始和结束位置不断变化,并通过改变其渲染的起始和结束位置来改变其形状

    self.lineLayer = [CAShapeLayer layer];
    self.lineLayer.frame = self.bounds;
    self.lineLayer.lineWidth = SURefreshPointRadius * 2;
    self.lineLayer.lineCap = kCALineCapRound;
    self.lineLayer.lineJoin = kCALineJoinRound;
    self.lineLayer.fillColor = topPointColor;
    self.lineLayer.strokeColor = topPointColor;
    UIBezierPath * path = [UIBezierPath bezierPath];
    [path moveToPoint:topPoint];
    [path addLineToPoint:leftPoint];
    [path moveToPoint:leftPoint];
    [path addLineToPoint:bottomPoint];
    [path moveToPoint:bottomPoint];
    [path addLineToPoint:rightPoint];
    [path moveToPoint:rightPoint];
    [path addLineToPoint:topPoint];
    self.lineLayer.path = path.CGPath;
    self.lineLayer.strokeStart = 0.f;
    self.lineLayer.strokeEnd = 0.f;
    [self.layer insertSublayer:self.lineLayer above:self.TopPointLayer];
3)滑动过程控制动画进度

该步骤的核心是通过下拉的长度计算LineLayer的开始和结束位置,并在适当的时候显示或隐藏对应的点

- (void)setLineLayerStrokeWithProgress:(CGFloat)progress {
    float startProgress = 0.f;
    float endProgress = 0.f;
    
    //没有下拉,隐藏动画
    if (progress < 0) {
        self.TopPointLayer.opacity = 0.f;
        [self adjustPointStateWithIndex:0];
    }
    //下拉前奏:顶部的Point的可见度渐变的过程
    else if (progress >= 0 && progress < (SURefreshPullLen - 40)) {
        self.TopPointLayer.opacity = progress / 20;
        [self adjustPointStateWithIndex:0];
    }
    //开始动画,这里将下拉的进度分为4个大阶段,方便处理,请看前面的描述
    else if (progress >= (SURefreshPullLen - 40) && progress < SURefreshPullLen) {
        self.TopPointLayer.opacity = 1.0;
        //大阶段 0 ~ 3
        NSInteger stage = (progress - (SURefreshPullLen - 40)) / 10;
        //对应每个大阶段的前半段,请看前面描述
        CGFloat subProgress = (progress - (SURefreshPullLen - 40)) - (stage * 10);
        if (subProgress >= 0 && subProgress <= 5) {
            [self adjustPointStateWithIndex:stage * 2];
            startProgress = stage / 4.0;
            endProgress = stage / 4.0 + subProgress / 40.0 * 2;
        }
        //对应每个大阶段的后半段,请看前面描述
        if (subProgress > 5 && subProgress < 10) {
            [self adjustPointStateWithIndex:stage * 2 + 1];
            startProgress = stage / 4.0 + (subProgress - 5) / 40.0 * 2;
            if (startProgress < (stage + 1) / 4.0 - 0.1) {
                startProgress = (stage + 1) / 4.0 - 0.1;
            }
            endProgress = (stage + 1) / 4.0;
        }
    }
    //下拉超过一定长度,4个点已经完全显示
    else {
        self.TopPointLayer.opacity = 1.0;
        [self adjustPointStateWithIndex:NSIntegerMax];
        startProgress = 1.0;
        endProgress = 1.0;
    }
    //计算完毕,设置LineLayer的开始和结束位置
    self.lineLayer.strokeStart = startProgress;
    self.lineLayer.strokeEnd = endProgress;
}

- (void)adjustPointStateWithIndex:(NSInteger)index { //index : 小阶段: 0 ~ 7
    self.LeftPointLayer.hidden = index > 1 ? NO : YES;
    self.BottomPointLayer.hidden = index > 3 ? NO : YES;
    self.rightPointLayer.hidden = index > 5 ? NO : YES;
    self.lineLayer.strokeColor = index > 5 ? rightPointColor : index > 3 ? bottomPointColor : index > 1 ? leftPointColor : topPointColor;
}

4)达到条件时进入刷新状态

进入刷新状态的条件:下拉长度超过我们指定的长度,且手已离开屏幕(即scrollView没有处于拖动的状态),且没有正在播放Loading动画。
进入刷新状态时,同时执行下拉刷新时需要执行的操作(如加载网络数据等等)

//如果不是正在刷新,则渐变动画
    if (!self.animating) {
        if (progress >= SURefreshPullLen) {
            self.y = - (SURefreshPullLen - (SURefreshPullLen - SURefreshHeaderHeight) / 2);
        }else {
            if (progress <= self.h) {
                self.y = - progress;
            }else {
                self.y = - (self.h + (progress - self.h) / 2);
            }
        }
        [self setLineLayerStrokeWithProgress:progress];
    }
    //如果到达临界点,则执行刷新动画
    if (progress >= SURefreshPullLen && !self.animating && !self.scrollView.dragging) {
        [self startAni];
        if (self.handle) {
            self.handle();
        }
    }

执行Loading动画,我们采用CA动画来实现
scrollView的下沉动画

[UIView animateWithDuration:0.5 animations:^{
        UIEdgeInsets inset = self.scrollView.contentInset;
        inset.top = SURefreshPullLen;
        self.scrollView.contentInset = inset;
    }];

4个点的来回移动动画

    [self addTranslationAniToLayer:self.TopPointLayer xValue:0 yValue:SURefreshTranslatLen];
    [self addTranslationAniToLayer:self.LeftPointLayer xValue:SURefreshTranslatLen yValue:0];
    [self addTranslationAniToLayer:self.BottomPointLayer xValue:0 yValue:-SURefreshTranslatLen];
    [self addTranslationAniToLayer:self.rightPointLayer xValue:-SURefreshTranslatLen yValue:0];
- (void)addTranslationAniToLayer:(CALayer *)layer xValue:(CGFloat)x yValue:(CGFloat)y {
    CAKeyframeAnimation * translationKeyframeAni = [CAKeyframeAnimation animationWithKeyPath:@"transform"];
    translationKeyframeAni.duration = 1.0;
    translationKeyframeAni.repeatCount = HUGE;
    translationKeyframeAni.removedOnCompletion = NO;
    translationKeyframeAni.fillMode = kCAFillModeForwards;
    translationKeyframeAni.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionLinear];
    NSValue * fromValue = [NSValue valueWithCATransform3D:CATransform3DMakeTranslation(0, 0, 0.f)];
    NSValue * toValue = [NSValue valueWithCATransform3D:CATransform3DMakeTranslation(x, y, 0.f)];
    translationKeyframeAni.values = @[fromValue, toValue, fromValue, toValue, fromValue];
    [layer addAnimation:translationKeyframeAni forKey:@"translationKeyframeAni"];
}

RefreshHeader的整体旋转动画

[self addRotationAniToLayer:self.layer];
- (void)addRotationAniToLayer:(CALayer *)layer {
    CABasicAnimation * rotationAni = [CABasicAnimation animationWithKeyPath:@"transform.rotation.z"];
    rotationAni.fromValue = @(0);
    rotationAni.toValue = @(M_PI * 2);
    rotationAni.duration = 1.0;
    rotationAni.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionLinear];
    rotationAni.repeatCount = HUGE;
    rotationAni.fillMode = kCAFillModeForwards;
    rotationAni.removedOnCompletion = NO;
    [layer addAnimation:rotationAni forKey:@"rotationAni"];
}
5)回复初始状态

当用户拖动的长度达不到临界值,或者结束Loading的状态时,RefreshHeaderView移除所有的动画,回复到初始状态

- (void)removeAni {
    [UIView animateWithDuration:0.5 animations:^{
        UIEdgeInsets inset = self.scrollView.contentInset;
        inset.top = 0.f;
        self.scrollView.contentInset = inset;
    } completion:^(BOOL finished) {
        [self.TopPointLayer removeAllAnimations];
        [self.LeftPointLayer removeAllAnimations];
        [self.BottomPointLayer removeAllAnimations];
        [self.rightPointLayer removeAllAnimations];
        [self.layer removeAllAnimations];
        [self adjustPointStateWithIndex:0];
        self.animating = NO;
    }];
}

动画添加

我们创建一个UIScrollView的分类,添加一个给ScrollView添加RefreshHeader的方法

- (void)addRefreshHeaderWithHandle:(void (^)())handle {
    SURefreshHeader * header = [[SURefreshHeader alloc]init];
    header.handle = handle;
    self.header = header;
    [self insertSubview:header atIndex:0];
}

需要注意的是,由于分类中不能直接添加Property,我们采用关联对象的方法将RefreshHeader和ScrollView绑定

objc_setAssociatedObject(self, @selector(header), header, OBJC_ASSOCIATION_ASSIGN);

思考:这里为什么用ASSIGN这个关联策略

此外,由于ScrollView销毁的时候,RefreshHeader也销毁,但是由于RefreshHeader是ScrollView的观察者,不移除将导致应用崩溃,因此在销毁ScrollView之前需要将观察者移除,这里采用方法交换在Dealloc方法里面将观察者移除。

+ (void)load {
    Method originalMethod = class_getInstanceMethod([self class], NSSelectorFromString(@"dealloc"));
    Method swizzleMethod = class_getInstanceMethod([self class], NSSelectorFromString(@"su_dealloc"));
    method_exchangeImplementations(originalMethod, swizzleMethod);
}

- (void)su_dealloc {
    self.header = nil;
    [self su_dealloc];
}

思考:在本代码中ScrollView、RefreshHeader、RefreshBlock三者的引用关系是怎样的?尝试画出一个示意图,加深对内存管理的理解。

到这里,我们就可以使用自己写的下拉刷新库应用在工程中了,就像使用MJRefresh一样方便。
[self.tableView addRefreshHeaderWithHandle:^{
        //请求网络数据
    }];
//请求完成后
[tableView.header endRefreshing];

Demo

本文的demo在我的github上可以下载:GitHub : SURefresh

Next

布吉岛布吉岛

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

推荐阅读更多精彩内容

  • 发现 关注 消息 iOS 第三方库、插件、知名博客总结 作者大灰狼的小绵羊哥哥关注 2017.06.26 09:4...
    肇东周阅读 11,964评论 4 60
  • Swift版本点击这里欢迎加入QQ群交流: 594119878最新更新日期:18-09-17 About A cu...
    ylgwhyh阅读 25,224评论 7 249
  • 如果今天我把故事讲给朋友,我的朋友一定会觉得“你怎么这么惨”。 是啊,我现在蹲在知味观的门前,刚刚看了一个像90年...
    Serenawanwan阅读 362评论 0 0
  • pp私房菜:番茄海鲜锅 ​周五公司年会,抽了个五百块,第一次中奖啊,第一次!!!​在糗百过了两个年会了,糗百的年会...
    摸鱼哥阅读 495评论 4 2
  • 假如,我就这样开篇,会不会觉得太过于唐突,那么,好吧,我换种方式。 第一次听sad angel 是在英语课,课间休...
    北尚阅读 498评论 0 1