iOS-UIView与CALayer动画原理

一 关于CoreAnimation

CoreAnimation是苹果提供的一套基于绘图的动画框架,下图是官方文档中给出的体系结构。

CoreAnimation.png

从图中可以看出,最底层是图形硬件(GPU);上层是OpenGL和CoreGraphics,提供一些接口来访问GPU;再上层的CoreAnimation在此基础上封装了一套动画的API。最上面的UIKit属于应用层,处理与用户的交互。所以,学习CoreAnimation也会涉及一些图形学的知识,了解这些有助于我们更顺手的使用以及更高效的解决问题。

二 CALayer详解

CoreAnimation属于QuartzCore框架,Quartz原本是macOS的Darwin核心之上的绘图技术。在iOS中,我们所看到的视图UIView是通过QuartzCore中的CALayer显示出来的,我们讨论的动画效果也是加在这个CALayer上的。

CALayer图层类是CoreAnimation的基础,它提供了一套抽象概念。CALayer是整个图层类的基础,它是所有核心动画图层的父类

2.1 CALayer

为什么UIView要加一层Layer来负责显示呢?我们知道QuartzCore是跨iOS和macOS平台的,而UIView属于UIKit是iOS开发使用的,在macOS中对应AppKit里的NSView。这是因为macOS是基于鼠标指针操作的系统,与iOS的多点触控有本质的区别。虽然iOS在交互上与macOS有所不同,但在显示层面却可以使用同一套技术。

每一个UIView都有个属性layer、默认为CALayer类型,也可以使用自定义的Layer

/* view的leyer,view是layer的代理 */
@property(nonatomic,readonly,strong) CALayer  *layer;

可以想象我们看到的View其实都是它的layer,下面我们通过CALayer中的集合相关的属性来认识它

  • bounds:图层的bounds是一个CGRect的值,指定图层的大小(bounds.size)和原点(bounds.origin)
  • position:指定图层的位置(相对于父图层而言)
  • anchorPoint:锚点指定了position在当前图层中的位置,坐标范围0~1。position点的值是相对于父图层的,而这个position到底位于当前图层的什么地方,是由锚点决定的。(默认在图层的中心,即锚点为(0.5,0.5) )
  • transform:指定图层的几何变换,类型为上篇说过的CATransform3D

这些属性的注释最后都有一句Animatable,就是说我们可以通过改变这些属性来实现动画。默认地,我们修改这些属性都会导致图层从旧值动画显示为新值,称为隐式动画

注意到frame的注释里面是没有Animatable的。事实上,我们可以理解为图层的frame并不是一个真实的属性:当我们读取frame时,会根据图层position、bounds、anchorPoint和transform的值计算出它的frame;而当我们设置frame时,图层会根据anchorPoint改变position和bounds。也就是说frame本身并没有被保存

图层不但给自己提供可视化的内容和管理动画,而且充当了其他图层的容器类,构建图层层次结构

图层树类似于UIView的层次结构,一个view实例拥有父视图(superView)和子视图(subView);同样一个layer也有父图层(superLayer)和子图层(subLayer)。我们可以直接在view的layer上添加子layer达到一些显示效果,但这些单独的layer无法像UIView那样进行交互响应。

三 CAAnimation

CALayer提供以下方法来管理动画:

- (void)addAnimation:(CAAnimation*)anim forKey:(nullable NSString*)key;
- (void)removeAllAnimations;
- (void)removeAnimationForKey:(NSString*)key;
- (nullable NSArray<NSString*>*)animationKeys;
- (nullable CAAnimation*)animationForKey:(NSString*)key;

CAAnimation动画基类,我们常用的CABasicAnimationCAKeyframeAnimation都继承于CAPropertyAnimation属性动画。属性动画通过改变layer的可动画属性(位置、大小等)实现动画效果。CABasicAnimation可以看做有两个关键帧的CAKeyframeAnimation,通过插值形成一条通过各关键帧的动画路径。但CABasicAnimation更加灵活一些:

  • CABasicAnimation
@interface CABasicAnimation : CAPropertyAnimation
@property(nullable, strong) id fromValue;
@property(nullable, strong) id toValue;
@property(nullable, strong) id byValue;
@end
我们可以通过上面三个值来规定CABasicAnimation的动画起止状态

这三个属性都是可选的,通常给定其中一个或者两个,以下是官方建议的使用方式

  • 给定fromValue和toValue,将在两者之间进行插值 *
  • 给定fromValue和byValue,将在fromValue和fromValue+byValue之间插值 *
  • 给定byValue和toValue,将在toValue-byValue和toValue之间插值 *
  • 仅给定fromValue,将在fromValue和当前值之间插值 *
  • 仅给定toValue,将在当前值和toValue之间插值 *
  • 仅给定byValue,将在当前值和当前值+byValue之间插值 *

在CAKeyframeAnimation中,除了给定各关键帧之外还可以指定关键帧之间的时间和时间函数:

  • CAKeyframeAnimation
@interface CAKeyframeAnimation : CAPropertyAnimation

@property(nullable, copy) NSArray *values;
@property(nullable, copy) NSArray<NSNumber *> *keyTimes;
/* 时间函数有线性、淡入、淡出等简单效果,还可以指定一条三次贝塞尔曲线 */
@property(nullable, copy) NSArray<CAMediaTimingFunction *> *timingFunctions;

@end

到这我们已经能够感觉到,所谓动画实际上就是在不同的时间显示不同画面,时间在走进而形成连续变化的效果。所以,动画的关键就是对时间的控制。

四 CAMediaTiming

CAMediaTiming是CoreAnimation中一个非常重要的协议,CALayer和CAAnimation都实现了它来对时间进行管理。

协议定义了8个属性,通过它们来控制时间,这些属性大都见名知意:

@protocol CAMediaTiming

@property CFTimeInterval beginTime;
@property CFTimeInterval duration;
@proterty float speed;
/* timeOffset时间的偏移量,用它可以实现动画的暂停、继续等效果*/
@proterty CFTimeInterval timeOffset;
@property float repeatCount;
@property CFTimeInterval repeatDuration;
/* autoreverses为true时时间结束后会原路返回,默认为false */
@property BOOL autoreverses;
/* fillMode填充模式,有4种,见下 */
@property(copy) NSString *fillMode;

@end

需要注意的是,CALayer也实现了CAMediaTiming协议,也就是说如果我们将layer的speed设置为2,那么加到这个layer上的动画都会以两倍速执行。

上面从图层、动画和时间控制的关系上简单认识了CALayer、属性动画和动画时间控制,了解属性动画是根据时间在各关键帧之间进行插值,随时间连续改变layer的某动画属性来实现的。

五 UIView与CALayer动画原理

下面从以下两点结合具体代码来探索下CoreAnimation的一些原理

1.UIView动画实现原理
2.展示层(presentationLayer)和模型层(modelLayer)

5.1 UIView动画实现原理

UIView提供了一系列animateWithDuration:animations:,我们只需要把改变可动画属性的代码放在animations的block中即可实现动画效果,例如

// 动画改变大小
- (void)animateChangeSize {
    [UIView animateWithDuration:1 animations:^(void){
        if (self.redView.width > 150) {
            self.redView.bounds = CGRectMake(0, 0, 100, 100);
        } else {
            self.redView.bounds = CGRectMake(0, 0, 200, 200);
        }
    } completion:^(BOOL finished){
        NSLog(@"%d",finished);
    }];
}

效果如下

5.1动画效果.gif

前面说过,UIView对象持有一个CALayer,真正来做动画的是这个layer,UIView只是对它做了一层封装,可以通过一个简单的实验验证一下:我们写一个MyTestLayer类继承CALayer,并重写它的set方法;再写一个MyTestView类继承UIView,重写它的layerClass方法指定图层类为MyTestLayer。
相关代码如下

// MyTestLayer.m
@implementation MyTestLayer
- (void)setBounds:(CGRect)bounds {
    NSLog(@"----layer setBounds");
    [super setBounds:bounds];
    NSLog(@"----layer setBounds end");
}

- (CGRect)bounds {
    NSLog(@"----layer getBounds");
    return [super bounds];
}
@end

// MyTestView.m
@implementation MyTestView

- (void)setBounds:(CGRect)bounds {
    NSLog(@"----view setBounds");
    [super setBounds:bounds];
    NSLog(@"----view setBounds end");
}

- (CGRect)bounds {
    NSLog(@"----view getBounds");
    return [super bounds];
}

+(Class)layerClass {
    return [MyTestLayer class];
}
@end

当我们给view设置bounds时,getter、setter的调用顺序如下

getter,setter调用顺序.png

也就是说,在view的setBounds方法中,会调用layer的setBounds;同样view的getBounds也会调用layer的getBounds。其他属性也会得到相同的结论。那么动画又是怎么产生的呢?当我们layer的属性发生变化时,会调用代理方法actionForLayer: forKey:来获得这次属性变化的动画方案,而view就是它所持有的layer的代理

@interface CALayer : NSObject <NSCoding, CAMediaTiming>
...
@property(nullable, weak) id <CALayerDelegate> delegate;
...
@end

@protocol CALayerDelegate <NSObject>
@optional
...
/* If defined, called by the default implementation of the
 * -actionForKey: method. Should return an object implementating the
 * CAAction protocol. May return 'nil' if the delegate doesn't specify
 * a behavior for the current event. Returning the null object (i.e.
 * '[NSNull null]') explicitly forces no further search. (I.e. the
 * +defaultActionForKey: method will not be called.) */
- (nullable id<CAAction>)actionForLayer:(CALayer *)layer forKey:(NSString *)event;
...
@end

注释中说明,该方法返回一个实现了CAAction的对象,通常是一个动画对象;当返回nil时执行默认的隐式动画,返回null时不执行动画。还是上面那个改变bounds的动画,我们在MyTestView中重写actionForLayer:方法

- (id<CAAction>)actionForLayer:(CALayer *)layer forKey:(NSString *)event {
    id<CAAction> action = [super actionForLayer:layer forKey:event];
    return action;
}

观察它的返回值

CABasicAnimation.png

是一个内部使用的_UIViewAddtiveAnimationAction对象,其中包含一个CABassicAnimation,默认fillMode为both,默认时间函数为淡入淡出,只包含fromValue(即动画之前的值,会在这个值和当前值(block中修改过后的值)之间做动画)。我们可以尝试在重写的这个方法中强制返回nil,会发现我们不写任何动画的代码直接改变属性也将产生一个默认0.25s的隐式动画,这和上面的注释描述是一致的。

如果两个动画重叠在一起会是什么效果呢?

还是用之前的例子,我们添加两个相同的UIView动画,一个时间为3s,一个时间为1s,并打印finished的值。先执行3s的动画,当它还没有结束时加上一个1s的动画,可以先看下实际效果:

动画叠加.gif
image.png

很明显,两个动画的finished都为true且时间也是我们设置好的3s和1s。也就是说第二个动画并不会打断第一个动画的执行,而是将动画进行了叠加。

下面对现象进行分析

  • 最开始方块的bounds为(100,100),点击执行3s动画,bounds变为(200,200),并开始展示变大的动画;
  • 动画过程中(假设到了(120,120)),点击1s动画,由于这时真实bounds已经是(200,200)了,所以bounds将变回100,并产生一个fromValue为(200,200)的动画。
image.png

但此时方块并没有从200开始,而是马上开始变小,并明显变到一个比100更小的值。

  • 1s动画结束,finished为1,耗时1s。此时屏幕上的方块是一个比100还要小的状态,又缓缓变回到100—3s动画结束,finished为1,耗时3s,方块最终停在(100,100)的大小。
结论:

从这个现象我们可以猜想UIView动画的叠加方式:当我们通过改变View属性实现动画时,这个属性的值是会立即改变的,动画只是展示出来的效果。当动画还未结束时如果对同个属性又加上另一个动画,两个动画会从当前展示的状态开始进行叠加,并最终停在view的真实位置。

举个通俗点的例子,我们8点从家出发,要在9点到达学校,我们按照正常的步速行走,这可以理解为一个动画;假如我们半路突然想到忘记带书包了,需要回家拿书包(相当于又添加了一个动画),这时我们肯定需要加快步速,当我们拿到书包时相当于第二个动画结束了,但我们上学这个动画还要继续执行,我们要以合适的速度继续往学校赶,保证在9点准时到达终点—学校。

所以刚才那个方块为什么会有一个比100还小的过程就不难理解了:当第二个动画加上去的时候,由于它是一个1s由200变为100的动画,肯定要比3s动画执行的快,而且是从120的位置开始执行的,所以一定会朝反方向变化到比100还小;1s动画结束后,又会以适当的速度在3s的时间点回到最终位置(100,100)。当然叠加后的整个过程在内部实现中可能是根据时间函数已经计算好的

设置属性值是立即生效的,动画只是看上去的效果。

5.2 展示层(presentationLayer)和模型层(modelLayer)

我们知道UIView动画其实是layer层做的,而view是对layer的一层封装,我们对view的bounds等这些属性的操作其实都是对它所持有的layer进行操作,我们做一个简单的实验—在UIView动画的block中改变view的bounds后,分别查看下view和layer的bounds的实际值:

// 动画改变大小
- (void)animateChangeSize {
    self.redView.bounds = CGRectMake(0, 0, 100, 100);
    [UIView animateWithDuration:3.0 animations:^(void){
        self.redView.bounds = CGRectMake(0, 0, 200, 200);
    } completion:^(BOOL finished){
        NSLog(@"%d",finished);
    }];
}

分别打印view和layer的bounds的值

image.png

都已经变成了(200,200),这是肯定的,之前已经验证过set view的bounds实际上就是set 它的layer的bounds。可动画不是layer实现的么?layer也已经到达终点了,它是怎么将动画展示出来的呢?
这里就要提到CALayer的两个实例方法presentationLayermodelLayer

@interface CALayer : NSObject <NSSecureCoding, CAMediaTiming>

/* Returns a copy of the layer containing all properties as they were
 * at the start of the current transaction, with any active animations
 * applied. This gives a close approximation to the version of the layer
 * that is currently displayed. Returns nil if the layer has not yet
 * been committed.
 *
 * The effect of attempting to modify the returned layer in any way is
 * undefined.
 *
 * The `sublayers', `mask' and `superlayer' properties of the returned
 * layer return the presentation versions of these properties. This
 * carries through to read-only layer methods. E.g., calling -hitTest:
 * on the result of the -presentationLayer will query the presentation
 * values of the layer tree. */

/* 以下参考官方api注释 */
/* presentationLayer
 * 返回一个layer的拷贝,如果有任何活动动画时,包含当前状态的所有layer属性
 * 实际上是逼近当前状态的近似值。
 * 尝试以任何方式修改返回的结果都是未定义的。
 * 返回值的sublayers 、mask、superlayer是当前layer的这些属性的presentationLayer
 */
- (nullable instancetype)presentationLayer;

/* When called on the result of the -presentationLayer method, returns
 * the underlying layer with the current model values. When called on a
 * non-presentation layer, returns the receiver. The result of calling
 * this method after the transaction that produced the presentation
 * layer has completed is undefined. */

/* modelLayer
 * 对presentationLayer调用,返回当前模型值。
 * 对非presentationLayer调用,返回本身。
 * 在生成表示层的事务完成后调用此方法的结果未定义。
 */
- (instancetype)modelLayer;

从注释不难看出,这个presentationLayer即是我们看到的屏幕上展示的状态,而modelLayer就是我们设置完立即生效的真实状态。

我们动画开始后延迟0.1s分别打印layer,layer.presentationLayer,layer.modelLayer和layer.presentationLayer.modelLayer :

dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
    NSLog(@"layer:%@",self.redView.layer);
    NSLog(@"layer.presentationLayer:%@",self.redView.layer.presentationLayer);
    NSLog(@"layer.modelLayer:%@",self.redView.layer.modelLayer);
    NSLog(@"layer.presentationLayer.modelLayer:%@",self.redView.layer.presentationLayer.modelLayer);
});

运行结果

image.png

明显,layer.presentationLayer是动画当前状态的值,而layer.modelLayerlayer.presentationLayer.modelLayer都是layer本身。

到这里,CALayer动画的原理基本清晰了,当有动画加入时,presentationLayer不断的(从按某种插值或逼近得到的动画路径上)取值来进行展示,当动画结束被移除时则取modelLayer的状态展示。这也是为什么我们用CABasicAnimation时,设定当前值为fromValue时动画执行结束又会回到起点的原因,实际上动画结束并不是回到起点而是到了modelLayer的位置。

虽然我们可以使用fillMode控制它结束时保持状态,但这种方法在动画执行完之后并没有将动画从渲染树中移除(因为我们需要设置animation.removedOnCompletion = NO才能让fillMode生效)。如果我们想让动画停在终点,更合理的办法是一开始就将layer设置成终点状态,其实前文提到的UIView的block动画就是这么做的。

如果我们一开始就将layer设置成终点状态再加入动画,会不会造成动画在终点位置闪一下呢?其实是不会的,因为我们看到的实际上是presentationLayer,而我们修改layer的属性,presentationLayer是不会立即改变的:

- (void)test {
    MyTestView *view = [[MyTestView alloc]initWithFrame:CGRectMake(200, 200, 100, 100)];
    [self.view addSubview:view];
    
    view.center = CGPointMake(1000, 1000);
    
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)((1/60) * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        NSLog(@"presentationLayer %@ y %f",view.layer.presentationLayer, view.layer.presentationLayer.position.y);
        NSLog(@"layer.modelLayer %@ y %f",view.layer.modelLayer,view.layer.modelLayer.position.y);
    });
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)((1/10) * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        NSLog(@"presentationLayer %@ y %f",view.layer.presentationLayer, view.layer.presentationLayer.position.y);
        NSLog(@"layer.modelLayer %@ y %f",view.layer.modelLayer,view.layer.modelLayer.position.y);
    });
}

在上面代码中我们改变view的center,modelLayer是立即改变的因为它就是layer本身。但presentationLayer是没有变的,我们尝试延迟一定时间再去取presentationLayer,发现它是在一个很短的时间之后才发生变化的,这个时间跟具体设备的屏幕刷新频率有关。也就是说我们给layer设置属性后,当下次屏幕刷新时,presentationLayer才会获取新值进行绘制。因为我们不可能对每一次属性修改都进行一次绘制,而是将这些修改保存在model层,当下次屏幕刷新时再统一取model层的值重绘。

如果我们添加了动画,并将modelLayer设置到终点位置,下次屏幕刷新时,presentationLayer会优先从动画中取值来绘制,所以并不会造成在终点位置闪一下。

总结
  • UIView持有一个CALayer负责展示,view是这个layer的delegate。改变view的属性实际上是在改变它持有的layer的属性,layer属性发生改变时会调用代理方法actionForLayer: forKey:来得知此次变化是否需要动画。对同一个属性叠加动画会从当前展示状态开始叠加并最终停在modelLayer真实位置。
  • CALayer内部控制两个属性presentationLayermodelLayermodelLayer为当前layer真实的状态,presentationLayer为当前layer屏幕上展示的状态presentationLayer会在每次屏幕刷新时更新状态,如果有动画则根据动画获取当前状态进行绘制,动画移除后则取modelLayer的状态

本文参考 zmmzxxx 的CSDN 博客 ,iOS CALayer与iOS动画 讲解及使用


  • 如有错误,欢迎指正,多多点赞,打赏更佳,您的支持是我写作的动力。

项目连接地址 - AnimationDemo

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

推荐阅读更多精彩内容