iOS下拉动画:仿华尔街见闻

简介

最近看到华尔街见闻下拉刷新动画,觉得挺好看的。于是决定模仿写一个demo,顺便发现华尔街见闻下拉刷新动画的1个疑似小问题,顺便解决了一下。
网络请求完成后,四个立方体会出现突然回到原始状态,会出现动画卡顿的情况(解决方案可以通过获取当前动画呈现树的位置,将立方体移动至标准形态后,再将立方体设置为原始状态)

下面先来看一下具体动画效果(转成gif图后动画效果变丑了,原效果更好看)
1123.gif

知识点梳理

知识点.png

动画实现步骤

动画的时间步骤大体上可以分为5步
一. 创建4个立方体设置其阴影
二. 设置立方体position并添加到CATransformLayer上,将CATransformLayer进行3D旋转
三. 设置根据下来百分比设置下拉过程动画
四. 设置网络请求时关键帧动画
五. 网络请求完成后根据获取当前动画呈现树状态,将4个立方体通过动画运动至标准形态。通知下拉刷新控件动画完成

详细实现说明

一、立方体创建

  1. iOS的3D坐标系

    立方体创建其实将6个平面进行3D变换组合而成的图形,因此我们先了解一下iOSX,Y,Z轴,以及围绕它们旋转的方向
    iOS3D坐标系.png

    由图所见,绕Z轴的旋转等同于之前二维空间的仿射旋转,但是绕X轴和Y轴的旋转就突破了屏幕的二维空间,并且在用户视角看来发生了倾斜。

    我们可以先创建一个上平面和下平面平行于iphone屏幕的立方体,上平面的Z坐标为halfCubeUnit,下平面的Z坐标为-halfCubeUnit。由于我们需要将平面的正面放在立方体的外侧,因此在平移立方体Z坐标后,还需要将下平面绕X轴旋转180°或者-180°。其他平面类似,因此我们就得到了一个上平面正对着我们的立方体,从用户视角看上去此时的立方体就相当于一个正方形。

//创建立方体的单个面
- (CALayer *)getFaceWithTransform:(CATransform3D)transform color:(UIColor *)color
{
    //create cube face layer
    CALayer *face = [CALayer layer];
    face.bounds = CGRectMake(0, 0, cubeUnit, cubeUnit);
    face.position = CGPointMake(0, 0);
    face.backgroundColor = color.CGColor;
    //不绘制背面
    face.doubleSided = NO;
    //apply the transform and return
    face.transform = transform;
    return face;
}

//创建承载立方体6个面的CATransformLayer
- (CALayer *)getCubeTransformLayerWithPosition:(CGPoint)position
{
    //将平面正面旋转为可视面
    //上下两面
    CATransform3D transTop = CATransform3DMakeTranslation(0, 0, halfCubeUnit);
    UIColor *colorTop = [UIColor colorWithRed:72/255. green:122/255. blue:200/255. alpha:1];
    CALayer *layerTop = [self getFaceWithTransform:transTop color:colorTop];
    
    CATransform3D transBottom = CATransform3DMakeTranslation(0, 0, -halfCubeUnit);
    transBottom = CATransform3DRotate(transBottom, M_PI, 1, 0, 0);
    CALayer *layerBottom= [self getFaceWithTransform:transBottom color:colorTop];

    //前后两面
    CATransform3D transBack = CATransform3DMakeTranslation(0, -halfCubeUnit, 0);
    transBack = CATransform3DRotate(transBack, M_PI_2, 1, 0, 0);
    UIColor *colorBack = [UIColor colorWithRed:89/255. green:117/255. blue:251/255. alpha:1];
    CALayer *layerBack = [self getFaceWithTransform:transBack color:colorBack];

    CATransform3D transFront = CATransform3DMakeTranslation(0, halfCubeUnit, 0);
    transFront = CATransform3DRotate(transFront, -M_PI_2, 1, 0, 0);
    CALayer *layerFront = [self getFaceWithTransform:transFront color:colorBack];

    //左右两面
    CATransform3D transLeft = CATransform3DMakeTranslation(-halfCubeUnit, 0, 0);
    transLeft = CATransform3DRotate(transLeft, -M_PI_2, 0, 1, 0);
    UIColor *colorLeft = [UIColor colorWithRed:60/255. green:81/255. blue:220/255. alpha:1];
    CALayer *layerLeft = [self getFaceWithTransform:transLeft color:colorLeft];

    CATransform3D transRight = CATransform3DMakeTranslation(halfCubeUnit, 0, 0);
    transRight = CATransform3DRotate(transRight, M_PI_2, 0, 1, 0);
    CALayer *layerRight = [self getFaceWithTransform:transRight color:colorLeft];
  1. CALayer阴影
    阴影往往可以达到图层深度暗示的效果。它通常由5个属性来控制
@property float shadowOpacity [0.0-1.0]控制着阴影的模糊度,为0时阴影非常确定的边界线。值越来越大的时候,边界线就会越来越模糊和自然
@property CGFloat shadowRadius 值越大阴影形状越模糊,图层的深度看上去就会更明显
@property(nullable) CGColorRef shadowColor 控制着阴影的颜色
@property CGSize shadowOffset 控制着阴影的方向和距离,默认值是 {0, -3}
@property(nullable) CGPathRef shadowPath 控制阴影的轮廓,使用此属性显式指定路径通常会提高渲染性能。此值默认为nil,此时图层轮廓是通过子图层的Alpha通道合成创建其阴影。实时计算阴影非常消耗资源,尤其是图层有多个子图层,每个图层还有一个有透明效果的寄宿图的情况

我们在创建立方体阴影的时候,使用了上平面的阴影。由于在绘制立方体6个面的时候,我们设置不绘制图层的的背面。因此立方体的下底面其实是没有绘制的,因此不能在下底面添加阴影。由于立方体之后要进行绕Z轴-45°旋转,阴影位置应设置在立方体左下方。

CGMutablePathRef path = CGPathCreateMutable();
CGPathAddRect(path, NULL, CGRectMake(0, 0, 10, 10));
layerTop.shadowPath = path;
CGPathRelease(path);
layerTop.shadowOffset = CGSizeMake(-30, 30);
layerTop.shadowColor = [UIColor grayColor].CGColor;
layerTop.shadowOpacity = 0.3;
layerTop.shadowRadius = 0.2;
layerTop.shouldRasterize = YES;
layerTop.rasterizationScale = [UIScreen mainScreen].scale;

二. CATransformLayer创建

  1. CATransformLayer简介
    其他的图层虽然能也能够对承载的内容进行3D变换的显示,但是它们其实是把它的子视图都平面化到一个场景中。CATransformLayer不同于普通的CALayer,因为它不能显示它自己的内容。只有当存在了一个能作用域子图层的变换它才真正存在。CATransformLayer并不平面化它的子图层,所以它能够用于构造一个层级的3D结构

  2. 添加立方体6面到CATransformLayer,通过CATransformLayer的3D变化就能看到立体效果的立方体了

    //添加到cubeLayer上
    CATransformLayer *cubeLayer = [CATransformLayer layer];
    cubeLayer.position = position;
    [cubeLayer addSublayer:layerTop];
    [cubeLayer addSublayer:layerBottom];
    [cubeLayer addSublayer:layerLeft];
    [cubeLayer addSublayer:layerRight];
    [cubeLayer addSublayer:layerFront];
    [cubeLayer addSublayer:layerBack];
    
    return cubeLayer;
}
  1. 创建4个承载立方体的CATransformLayer,设置它们position并添加到父CATransformLayer上。根据四个立方体的动画初始位置设置子CATransformLayer的position,并将父CATransformLayer延X轴旋转45°,在延Z轴旋转-45°。就可以看到立体效果的立方体
self.cube0 = [self getCubeTransformLayerWithPosition:self.cube0KeyPoints[0].CGPointValue];
self.cube1 = [self getCubeTransformLayerWithPosition:self.cube1KeyPoints[0].CGPointValue];
self.cube2 = [self getCubeTransformLayerWithPosition:self.cube2KeyPoints[0].CGPointValue];
self.cube3 = [self getCubeTransformLayerWithPosition:self.cube3KeyPoints[0].CGPointValue];
[self.transformLayer addSublayer:_cube0];
[self.transformLayer addSublayer:_cube1];
[self.transformLayer addSublayer:_cube2];
[self.transformLayer addSublayer:_cube3];
        
transform = CATransform3DIdentity;
transform = CATransform3DRotate(transform, M_PI_4, 1, 0, 0);    //绕x轴旋转45°
transform = CATransform3DRotate(transform, -M_PI_4, 0, 0, 1);   //绕z轴旋转-45°
self.transformLayer.transform = transform;

此时可以看到四个立方体动画前的初始状态如下图:
动画初始状态.png

三. 设置下拉过程动画

  1. 动画路径分析
    动画路径.png

    上图左上角显示了立方体动画的关键帧位置。动画其实是在6个位置值中循环,其中关键帧2和3位置相同,关键帧6和7位置相同。因此我们可以得到8个关键帧,图中红方块的动画关键帧如代码cube0KeyPoints所示,绿方块与红方块关键帧相差2代码cube1KeyPoints所示

- (NSArray <NSValue *>*)cube0KeyPoints
{
    if (!_cube0KeyPoints) {
        _cube0KeyPoints = @[
                            [NSValue valueWithCGPoint:CGPointMake(-halfCubeUnit, -cubeUnit)],
                            [NSValue valueWithCGPoint:CGPointMake(halfCubeUnit, -cubeUnit)],
                            [NSValue valueWithCGPoint:CGPointMake(halfCubeUnit, 0)],
                            [NSValue valueWithCGPoint:CGPointMake(halfCubeUnit, 0)],
                            [NSValue valueWithCGPoint:CGPointMake(halfCubeUnit, cubeUnit)],
                            [NSValue valueWithCGPoint:CGPointMake(-halfCubeUnit, cubeUnit)],
                            [NSValue valueWithCGPoint:CGPointMake(-halfCubeUnit, 0)],
                            [NSValue valueWithCGPoint:CGPointMake(-halfCubeUnit, 0)],
                            ];
    }
    return _cube0KeyPoints;
}

- (NSArray <NSValue *>*)cube1KeyPoints
{
    if (!_cube1KeyPoints) {
        NSMutableArray *keyPoints = [[NSMutableArray alloc] init];
        for (NSInteger index = 0; index < self.cube0KeyPoints.count; index++) {
            //立方体1的动画初始位置在step2
            [keyPoints addObject:self.cube0KeyPoints[(index + 2) % self.cube0KeyPoints.count]];
        }
        _cube1KeyPoints = keyPoints;
    }
    return _cube1KeyPoints;
}
  1. 从下拉动画那一栏可以看到,下拉过程四个立方体从形态0变化到了形态2, 我们可以将需要根据百分比获取四个立方体在形态变化过程中的位置,设置立方体的position就可以了。

3 .由于CALayer设置可动画属性是,默认是开启隐式动画的,我们需要将隐式动画禁止

- (void)refreshWithPercent:(CGFloat)percent
{
    [self resetCubes];
    
    //从关键帧0开始运动
    CGPoint point0 = [self getPositionWithPercent:percent index:0];
    //从关键帧2开始运动
    CGPoint point1 = [self getPositionWithPercent:percent index:1];
    //从关键帧4开始运动
    CGPoint point2 = [self getPositionWithPercent:percent index:2];
    //从关键帧6开始运动
    CGPoint point3 = [self getPositionWithPercent:percent index:3];
    
    //禁止隐式动画
    [CATransaction begin];
    [CATransaction setDisableActions:YES];
    _cube0.position = point0;
    _cube1.position = point1;
    _cube2.position = point2;
    _cube3.position = point3;
    [CATransaction commit];
}

//根据下拉距离的百分比控制立方体位置
- (CGPoint)getPositionWithPercent:(CGFloat)percent index:(NSInteger)index
{
    if(percent >= 1){
        percent = 1;
    }
    //下拉过程动画分为pullStep步,每一步所占百分比为1./animePullSteps
    CGFloat unitPercent = 1./animePullSteps;
    NSInteger step = (NSInteger)(percent/unitPercent);
    CGFloat subPercent = (percent - step*unitPercent)/unitPercent;
    NSArray <NSValue *>*cubeKeyPoints = self.allCubeKeyPoints[index];
    
    NSInteger count = cubeKeyPoints.count;
    CGPoint pointStart = cubeKeyPoints[step%count].CGPointValue;
    CGPoint pointEnd = cubeKeyPoints[(step+1)%count].CGPointValue;
    
    CGPoint point = CGPointMake(pointStart.x+(pointEnd.x-pointStart.x)*subPercent, pointStart.y+(pointEnd.y-pointStart.y)*subPercent);
    return point;
}

四. 刷新时关键帧动画
刷新过程中其实就是8个关键帧的无限循环。

//开始刷新动画
- (void)startAnimation
{
    [self resetCubes];
    
    NSInteger valueCount = animeRefreshSteps + 1;
    CAAnimation *animation0 = [self animationWithIndex:0 step:0 valueCount:valueCount];
    CAAnimation *animation1 = [self animationWithIndex:1 step:0 valueCount:valueCount];
    CAAnimation *animation2 = [self animationWithIndex:2 step:0 valueCount:valueCount];
    CAAnimation *animation3 = [self animationWithIndex:3 step:0 valueCount:valueCount];
    [animation0 setRepeatCount:CGFLOAT_MAX];
    [animation1 setRepeatCount:CGFLOAT_MAX];
    [animation2 setRepeatCount:CGFLOAT_MAX];
    [animation3 setRepeatCount:CGFLOAT_MAX];
    CFTimeInterval beginTime = CACurrentMediaTime()+0.1;
    animation0.beginTime = beginTime;
    animation1.beginTime = beginTime;
    animation2.beginTime = beginTime;
    animation3.beginTime = beginTime;
    
    [_cube0 addAnimation:animation0 forKey:@"animiation"];
    [_cube1 addAnimation:animation1 forKey:@"animiation"];
    [_cube2 addAnimation:animation2 forKey:@"animiation"];
    [_cube3 addAnimation:animation3 forKey:@"animiation"];
}
/**
 创建关键帧动画
 
 @param index 第几个立方体[0-3]
 @param step 从关键帧的第几步开始进行动画
 @param valueCount 从立方体关键帧数组中选取几步进行动画
 @return 关键帧动画
 */
- (CAKeyframeAnimation *)animationWithIndex:(NSInteger)index step:(NSInteger)step valueCount:(NSInteger)valueCount
{
    //生成对应的关键帧
    NSMutableArray *values = [[NSMutableArray alloc] init];
    //生成对应的timingFunctions
    NSMutableArray *timingFunctions = [[NSMutableArray alloc] init];
    //对应的立方体动画关键帧
    NSArray *cubeKeyPoints = self.allCubeKeyPoints[index];
    //获取cubeKeyPoints数组长度,防止数组越界
    NSInteger count = cubeKeyPoints.count;
    
    for (NSInteger index = 0; index < valueCount; index++) {
        
        [values addObject:cubeKeyPoints[(step+index) % count]];
        if (index != valueCount-1) {//timingFunctions.count 比 values.count 小1位
            [timingFunctions addObject:[CAMediaTimingFunction functionWithControlPoints:0.24 :0.52 :0.43 :0.8]];
        }
    }
    
    //创建关键帧动画
    CAKeyframeAnimation *animation = [CAKeyframeAnimation animationWithKeyPath:@"position"];
    animation.values = values;
    animation.duration = timeDuration/animeRefreshSteps * timingFunctions.count;
    animation.calculationMode = kCAAnimationLinear;
    animation.timingFunctions = timingFunctions;
    //动画结束后,保持最后一帧状态
    animation.removedOnCompletion = NO;
    animation.fillMode = kCAFillModeBoth;
    
    return animation;
}

五. 结束动画
上面提到华尔街见闻app中,在网络请求返回后由于四个立方体的组合成的形态并不定。突然从当期动画状态切换到动画初始状态会有卡顿的感觉,这里就需要解决这个问题


2018-09-13 14_40_07.gif

需要了解的知识点有两个

  1. 显示动画并不改变作用对象的属性。也就是说显示动画并不改变模型树的属性值,例如你通过显示使对象的position变化,并且在动画结束是保留动画的最终状态。但是无论是动画中还是动画结束,对象的position始终还是动画前的值。当你移除动画的时候,会看到对象又回到了之前位置
  2. 在动画中我们可以通过呈现树(presentationLayer)获取当前对象的当时的属性值。
    由于网络请求并不确定是什么时候返回,动画运行到什么样式也无法确定。因此我们可以在请求完成通过获取呈现树的position属性值,移除当前动画,添加新动画让立方体运动到标准形态。完成后通知刷新控件进行返回。
  3. 我们经常会使用beginTime = CACurrentMediaTime()+N来让动画延迟N秒执行,特别是动画组中运用的更加频繁。我们也可以使用beginTime = CACurrentMediaTime()-N,让动画从第N秒开始执行。这样就可以衔接之前已经进行过的动画。
- (void)stopAnimation
{
    //获取呈现树当前位置
    CGPoint point0 = self.cube0.presentationLayer.position;
    CGPoint point1 = self.cube1.presentationLayer.position;
    CGPoint point2 = self.cube2.presentationLayer.position;
    CGPoint point3 = self.cube3.presentationLayer.position;
    
    //去除承载立方体CATransformLayer的显示动画,停止动画
    [_cube0 removeAllAnimations];
    [_cube1 removeAllAnimations];
    [_cube2 removeAllAnimations];
    [_cube3 removeAllAnimations];
    
    NSArray <NSValue *>*presentPoints = @[[NSValue valueWithCGPoint:point0],[NSValue valueWithCGPoint:point1],[NSValue valueWithCGPoint:point2],[NSValue valueWithCGPoint:point3],];
    NSDictionary <NSString *, NSNumber *>*presentInfo = [self getPresentInfoWithPoints:presentPoints];
    CGFloat subPercent = presentInfo[@"subPercent"].floatValue;
    NSInteger step = presentInfo[@"step"].integerValue;
    NSInteger valueCount = [self animeEndSteps:step] + 1;
    
    CAAnimation *animation0 = [self animationWithIndex:0 step:step valueCount:valueCount];
    CAAnimation *animation1 = [self animationWithIndex:1 step:step valueCount:valueCount];
    CAAnimation *animation2 = [self animationWithIndex:2 step:step valueCount:valueCount];
    CAAnimation *animation3 = [self animationWithIndex:3 step:step valueCount:valueCount];
    animation0.delegate = self;
    
    CFTimeInterval beginTime = CACurrentMediaTime();
    animation0.beginTime = beginTime-subTimeDuration*subPercent;
    animation1.beginTime = beginTime-subTimeDuration*subPercent;
    animation2.beginTime = beginTime-subTimeDuration*subPercent;
    animation3.beginTime = beginTime-subTimeDuration*subPercent;
    
    [_cube0 addAnimation:animation0 forKey:@"animiation"];
    [_cube1 addAnimation:animation1 forKey:@"animiation"];
    [_cube2 addAnimation:animation2 forKey:@"animiation"];
    [_cube3 addAnimation:animation3 forKey:@"animiation"];
}

#pragma mark CAAnimationDelegate
- (void)animationDidStop:(CAAnimation *)anim finished:(BOOL)flag
{
    [self resetCubes];
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        if (self.animiationComplete) {
            self.animiationComplete();
        }
    });
}

最终效果


下拉刷新.gif

源码地址:https://gitee.com/dbmxl/PullRefreshAnimation

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

推荐阅读更多精彩内容

  • 1 CALayer IOS SDK详解之CALayer(一) http://doc.okbase.net/Hell...
    Kevin_Junbaozi阅读 5,148评论 3 23
  • 1、通过CocoaPods安装项目名称项目信息 AFNetworking网络请求组件 FMDB本地数据库组件 SD...
    阳明先生_X自主阅读 15,979评论 3 119
  • 男人孤独无助地在绚丽繁华的中央大街上彳亍着,脸上弥漫着一种哀伤的情绪,无论从哪种角度望去都难以发现可以喻之为喜悦的...
    七点本人阅读 277评论 0 0
  • 5月,终于过去。 这个月,话费异常,流量骤增,打了客服电话才知道,手机后台一直在自动更新该升级的各种app,象是手...
    李庆容阅读 382评论 0 0
  • 我未曾见过你, 但一直在等你, 在街角,在路口,在车站 你不认识我,我也不认识你 等你,便满心欢喜 未曾相遇,便可...
    破碗碗花阅读 282评论 0 4