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

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容