在 iOS 中,所有的 view 都是由一個底層的 layer 來驅動的。view 和它的 layer 之間有著緊密的聯絡,view 其實直接從 layer 物件中獲取了絕大多數它所需要的資料。在 iOS 中也有一些單獨的 layer,比如 AVCaptureVideoPreviewLayer
和 CAShapeLayer
,它們不需要附加到 view 上就可以在螢幕上顯示內容。兩種情況下其實都是 layer 在起決定作用。當然了,附加到 view 上的 layer 和單獨的 layer 在行為上還是稍有不同的。
基本上你改變一個單獨的 layer 的任何屬性的時候,都會觸發一個從舊的值過渡到新值的簡單動畫(這就是所謂的可動畫 animatable
)。然而,如果你改變的是 view 中 layer 的同一個屬性,它只會從這一幀直接跳變到下一幀。儘管兩種情況中都有 layer,但是當 layer 附加在 view 上時,它的預設的隱式動畫的 layer 行為就不起作用了。
animatable;幾乎所有的層的屬性都是隱性可動畫的。你可以在文件中看到它們的簡介是以 ‘animatable’ 結尾的。這不僅包括了比如位置,尺寸,顏色或者透明度這樣的絕大多數的數值屬性,甚至也囊括了像 isHidden 和 doubleSided 這樣的布林值。 像 paths 這樣的屬性也是 animatable 的,但是它不支援隱式動畫。
在 Core Animation 程式設計指南的 “How to Animate Layer-Backed Views” 中,對為什麼會這樣做出了一個解釋:
UIView 預設情況下禁止了 layer 動畫,但是在 animation block 中又重新啟用了它們
這正是我們所看到的行為;當一個屬性在動畫 block 之外被改變時,沒有動畫,但是當屬性在動畫 block 內被改變時,就帶上了動畫。對於這是如何發生的這一問題的答案十分簡單和優雅,它優美地闡明和揭示了 view 和 layer 之間是如何協同工作和被精心設計的。
無論何時一個可動畫的 layer 屬性改變時,layer 都會尋找並執行合適的 ‘action’ 來實行這個改變。在 Core Animation 的專業術語中就把這樣的動畫統稱為動作 (action,或者 CAAction
)。
CAAction:技術上來說,這是一個介面,並可以用來做各種事情。但是實際中,某種程度上你可以只把它理解為用來處理動畫。
layer 將像文件中所寫的的那樣去尋找動作,整個過程分為五個步驟。第一步中的在 view 和 layer 中互動的部分是最有意思的:
layer 通過向它的 delegate 傳送 actionForLayer:forKey:
訊息來詢問提供一個對應屬性變化的 action。delegate 可以通過返回以下三者之一來進行響應:
- 它可以返回一個動作物件,這種情況下 layer 將使用這個動作。
- 它可以返回一個
nil
, 這樣 layer 就會到其他地方繼續尋找。 - 它可以返回一個
NSNull
物件,告訴 layer 這裡不需要執行一個動作,搜尋也會就此停止。
而讓這一切變得有趣的是,當 layer 在背後支援一個 view 的時候,view 就是它的 delegate;
在 iOS 中,如果 layer 與一個 UIView 物件關聯時,這個屬性必須
被設定為持有這個 layer 的那個 view。
理解這些之後,前一分鐘解釋起來還複雜無比的現象瞬間就易如反掌了:屬性改變時 layer 會向 view 請求一個動作,而一般情況下 view 將返回一個 NSNull
,只有當屬性改變發生在動畫 block 中時,view 才會返回實際的動作。哈,但是請別輕信我的這些話,你可以非常容易地驗證到底是不是這樣。只要對一個一般來說可以動畫的 layer 屬性向 view 詢問動作就可以了,比如對於 ‘position’:
1 2 3 4 5 6 7 |
NSLog(@"outside animation block: %@", [myView actionForLayer:myView.layer forKey:@"position"]); [UIView animateWithDuration:0.3 animations:^{ NSLog(@"inside animation block: %@", [myView actionForLayer:myView.layer forKey:@"position"]); }]; |
執行上面的程式碼,可以看到在 block 外 view 返回的是 NSNull 物件,而在 block 中時返回的是一個 CABasicAnimation。很優雅,對吧?值得注意的是列印出的 NSNull 是帶著一對尖括號的 (“<null>
“),這和其他物件一樣,而列印 nil 的時候我們得到的是普通括號((null)
):
1 2 |
outside animation block: <null> inside animation block: <CABasicAnimation: 0x8c2ff10> |
對於 view 中的 layer 來說,對動作的搜尋只會到第一步為止(至少我沒有見過 view 返回一個 nil
然後導致繼續搜尋動作的情況)。對於單獨的 layer 來說,剩餘的四個步驟可以在 CALayer 的 actionForKey:
文件中找到。
從 UIKit 中學習
我很確定我們都會同意 UIView 動畫是一組非常優秀的 API,它簡潔明確。實際上,它使用了 Core Animation 來執行動畫,這給了我們一個絕佳的機會來深入研究 UIKit 是如何使用 Core Animation 的。在這裡甚至還有很多非常棒的實踐和技巧可以讓我們借鑑。:)
當屬性在動畫 block 中改變時,view 將向 layer 返回一個基本的動畫,然後動畫通過通常的 addAnimation:forKey:
方法被新增到 layer 中,就像顯式地新增動畫那樣。再一次,別直接信我,讓我們實踐檢驗一下。
歸功於 UIView 的 +layerClass
類方法,view 和 layer 之間的互動很容易被觀測到。通過這個方法我們可以在為 view 建立 layer 時為其指定要使用的類。通過子類一個 UIView,以及用這個方法返回一個自定義的 layer 類,我們就可以重寫 layer 子類中的 addAnimation:forKey:
並輸出一些東西來驗證它是否確實被呼叫。唯一要記住的是我們需要呼叫 super 方法,不然的話我們就把要觀測的行為完全改變了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
@interface DRInspectionLayer : CALayer @end @implementation DRInspectionLayer - (void)addAnimation:(CAAnimation *)anim forKey:(NSString *)key { NSLog(@"adding animation: %@", [anim debugDescription]); [super addAnimation:anim forKey:key]; } @end @interface DRInspectionView : UIView @end @implementation DRInspectionView + (Class)layerClass { return [DRInspectionLayer class]; } @end |
通過輸出動畫的 debug 資訊,我們不僅可以驗證它確實如預期一樣被呼叫了,還可以看到動畫是如何組織構建的:
1 2 3 4 5 6 7 8 |
<CABasicAnimation:0x8c73680; delegate = <UIViewAnimationState: 0x8e91fa0>; fillMode = both; timingFunction = easeInEaseOut; duration = 0.3; fromValue = NSPoint: {5, 5}; keyPath = position > |
當動畫剛被新增到 layer 時,屬性的新值還沒有被改變。在構建動畫時,只有 fromValue
(也就是當前值) 被顯式地指定了。CABasicAnimation 的文件向我們簡單介紹了這麼做對於動畫的插值來說的的行為應該是:
只有 fromValue
不是 nil
時,在 fromValue
和屬性當前顯示層的值之間進行插值。
這也是我在處理顯式動畫時選擇的做法,將一個屬性改變為新的值,然後將動畫物件新增到 layer 上:
1 2 3 4 5 6 7 |
CABasicAnimation *fadeIn = [CABasicAnimation animationWithKeyPath:@"opacity"]; fadeIn.duration = 0.75; fadeIn.fromValue = @0; myLayer.opacity = 1.0; // 更改 model 的值 ... // ... 然後新增動畫物件 [myLayer addAnimation:fadeIn forKey:@"fade in slowly"]; |
這很簡潔,你也不需要在動畫被移除的時候做什麼額外操作。如果動畫是在一段延遲後才開始的話,你可以使用 backward 填充模式 (或者 ‘both’ 填充模式),就像 UIKit 所建立的動畫那樣。
可能你看見上面輸出中的動畫的 delegate 了,想知道這個類是用來做什麼的嗎?我們可以來看看 dump 出來的標頭檔案, 它主要用來維護動畫的一些狀態 (持續時間,延時,重複次數等等)。它還負責對一個棧做 push 和 pop,這是為了在多個動畫 block 巢狀時能夠獲取正確的動畫狀態。這些都是些實現細節,除非你想要寫一套自己的基於 block 的動畫 API,否則可能你不會用到它們 (實際上這是一個很有趣的點子)。
然後真正有意思的是這個 delegate 實現了 animationDidStart:
和 animationDidStop:finished:
,並將資訊傳給了它自己的 delegate。
編者注 這裡不太容易理解,加以說明:從上面的標頭檔案中可以看出,作為 CAAnimation 的 delegate 的私有類 UIViewAnimationState
中還有一個 _delegate
成員,並且 animationDidStart:
和 animationDidStop:finished:
也是典型的 delegate 的實現方法。
通過列印這個 delegate 的 delegate,我們可以發現它也是一個私有類:UIViewAnimationBlockDelegate。同樣進行 class dump 得到它的標頭檔案,這是一個很小的類,只負責一件事情:響應動畫的 delegate 回撥並且執行相應的 block。如果我們使用自己的 Core Animation 程式碼,並且選擇 block 而不是 delegate 做回撥的話,新增這個是很容易的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
@interface DRAnimationBlockDelegate : NSObject @property (copy) void(^start)(void); @property (copy) void(^stop)(BOOL); +(instancetype)animationDelegateWithBeginning:(void(^)(void))beginning completion:(void(^)(BOOL finished))completion; @end @implementation DRAnimationBlockDelegate + (instancetype)animationDelegateWithBeginning:(void (^)(void))beginning completion:(void (^)(BOOL))completion { DRAnimationBlockDelegate *result = [DRAnimationBlockDelegate new]; result.start = beginning; result.stop = completion; return result; } - (void)animationDidStart:(CAAnimation *)anim { if (self.start) { self.start(); } self.start = nil; } - (void)animationDidStop:(CAAnimation *)anim finished:(BOOL)flag { if (self.stop) { self.stop(flag); } self.stop = nil; } @end |
雖然是我個人的喜好,但是我覺得像這樣的基於 block 的回撥風格可能會比實現一個 delegate 回撥更適合你的程式碼:
1 2 3 4 5 |
fadeIn.delegate = [DRAnimationBlockDelegate animationDelegateWithBeginning:^{ NSLog(@"beginning to fade in"); } completion:^(BOOL finished) { NSLog(@"did fade %@", finished ? @"to the end" : @"but was cancelled"); }]; |
自定義基於 block 的動畫 APIs
一旦你知道了 actionForKey:
的機理之後,UIView 就遠沒有它一開始看起來那麼神祕了。實際上我們完全可以按照我們的需求量身定製地寫出一套自己的基於 block 的動畫 APIs。我所設計的動畫將通過在 block 中用一個很激進的時間曲線來做動畫,以吸引使用者對該 view 的注意,之後做一個緩慢的動畫回到原始狀態。你可以把它看作一種類似 pop (請不要和 Facebook 最新的 Pop 框架弄混了)的行為。與一般使用 UIViewAnimationOptionAutoreverse
的動畫 block 不同,因為動畫設計和概念上的需要,我自己實現了將 model 值改變回原始值的過程。自定義的動畫 API 的使用方法就像這樣:
1 2 3 4 |
[UIView DR_popAnimationWithDuration:0.7 animations:^{ myView.transform = CGAffineTransformMakeRotation(M_PI_2); }]; |
當我們完成後,效果是這個樣子的 (對四個不同的 view 為位置,尺寸,顏色和旋轉進行動畫):
要開始實現它,我們首先要做的是當一個 layer 屬性變化時獲取 delegate 的回撥。因為我們無法事先預測 layer 要改變什麼,所以我選擇在一個 UIView 的 category 中 swizzle actionForLayer:forKey:
方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
@implementation UIView (DR_CustomBlockAnimations) + (void)load { SEL originalSelector = @selector(actionForLayer:forKey:); SEL extendedSelector = @selector(DR_actionForLayer:forKey:); Method originalMethod = class_getInstanceMethod(self, originalSelector); Method extendedMethod = class_getInstanceMethod(self, extendedSelector); NSAssert(originalMethod, @"original method should exist"); NSAssert(extendedMethod, @"exchanged method should exist"); if(class_addMethod(self, originalSelector, method_getImplementation(extendedMethod), method_getTypeEncoding(extendedMethod))) { class_replaceMethod(self, extendedSelector, method_getImplementation(originalMethod), method_getTypeEncoding(originalMethod)); } else { method_exchangeImplementations(originalMethod, extendedMethod); } } |
為了保證我們不破壞其他依賴於 actionForLayer:forKey:
回撥的程式碼,我們使用一個靜態變數來判斷現在是不是處於我們自己定義的上下文中。對於這個例子來說一個簡單的 BOOL
其實就夠了,但是如果我們之後要寫更多內容的話,上下文的話就要靈活得多了:
1 2 3 4 5 6 7 8 9 10 11 12 |
static void *DR_currentAnimationContext = NULL; static void *DR_popAnimationContext = &DR_popAnimationContext; - (id<CAAction>)DR_actionForLayer:(CALayer *)layer forKey:(NSString *)event { if (DR_currentAnimationContext == DR_popAnimationContext) { // 這裡寫我們自定義的程式碼... } // 呼叫原始方法 return [self DR_actionForLayer:layer forKey:event]; // 沒錯,你沒看錯。因為它們已經被交換了 } |
在我們的實現中,我們要確保在執行動畫 block 之前設定動畫的上下文,並且在執行後恢復上下文:
1 2 3 4 5 6 7 8 9 |
+ (void)DR_popAnimationWithDuration:(NSTimeInterval)duration animations:(void (^)(void))animations { DR_currentAnimationContext = DR_popAnimationContext; // 執行動畫 (它將觸發交換後的 delegate 方法) animations(); /* 一會兒再新增 */ DR_currentAnimationContext = NULL; } |
如果我們想要做的不過是新增一個從舊的值向新的值過度的動畫的話,我們可以直接在 delegate 的回撥中來做。然而因為我們想要更精確地控制動畫,我們需要用一個幀動畫來實現。幀動畫需要所有的值都是已知的,而對我們的情況來說,新的值還沒有被設 定,因此我們也就無從知曉。
有意思的是,iOS 新增的一個基於 block 的動畫 API 也遇到了同樣的問題。使用和上面一樣的觀察手段,我們就能知道它是如何繞開這個麻煩的。對於每個關鍵幀,在屬性變化時,view 返回 nil
,但是卻儲存下需要的狀態。這樣就能在所有關鍵幀 block 執行後建立一個 CAKeyframeAnimationz
物件。
受到這種方法的啟發,我們可以建立一個小的類來儲存我們建立動畫時所需要的資訊:什麼 layer 被更改了,什麼 key path 的值被改變了,以及原來的值是什麼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
@interface DRSavedPopAnimationState : NSObject @property (strong) CALayer *layer; @property (copy) NSString *keyPath; @property (strong) id oldValue; + (instancetype)savedStateWithLayer:(CALayer *)layer keyPath:(NSString *)keyPath; @end @implementation DRSavedPopAnimationState + (instancetype)savedStateWithLayer:(CALayer *)layer keyPath:(NSString *)keyPath { DRSavedPopAnimationState *savedState = [DRSavedPopAnimationState new]; savedState.layer = layer; savedState.keyPath = keyPath; savedState.oldValue = [layer valueForKeyPath:keyPath]; return savedState; } @end |
接下來,在我們的交換後的 delegate 回撥中,我們簡單地將被變更的屬性的狀態存入一個靜態可變陣列中:
1 2 3 4 5 6 7 |
if (DR_currentAnimationContext == DR_popAnimationContext) { [[UIView DR_savedPopAnimationStates] addObject:[DRSavedPopAnimationState savedStateWithLayer:layer keyPath:event]]; // 沒有隱式的動畫 (稍後新增) return (id<CAAction>)[NSNull null]; } |
在動畫 block 執行完畢後,所有的屬性都被變更了,它們的狀態也被儲存了。現在,建立關鍵幀動畫:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
+ (void)DR_popAnimationWithDuration:(NSTimeInterval)duration animations:(void (^)(void))animations { DR_currentAnimationContext = DR_popAnimationContext; // 執行動畫 (它將觸發交換後的 delegate 方法) animations(); [[self DR_savedPopAnimationStates] enumerateObjectsUsingBlock:^(id obj, NSUInteger idx, BOOL *stop) { DRSavedPopAnimationState *savedState = (DRSavedPopAnimationState *)obj; CALayer *layer = savedState.layer; NSString *keyPath = savedState.keyPath; id oldValue = savedState.oldValue; id newValue = [layer valueForKeyPath:keyPath]; CAKeyframeAnimation *anim = [CAKeyframeAnimation animationWithKeyPath:keyPath]; CGFloat easing = 0.2; CAMediaTimingFunction *easeIn = [CAMediaTimingFunction functionWithControlPoints:1.0 :0.0 :(1.0-easing) :1.0]; CAMediaTimingFunction *easeOut = [CAMediaTimingFunction functionWithControlPoints:easing :0.0 :0.0 :1.0]; anim.duration = duration; anim.keyTimes = @[@0, @(0.35), @1]; anim.values = @[oldValue, newValue, oldValue]; anim.timingFunctions = @[easeIn, easeOut]; // 不帶動畫地返回原來的值 [CATransaction begin]; [CATransaction setDisableActions:YES]; [layer setValue:oldValue forKeyPath:keyPath]; [CATransaction commit]; // 新增 "pop" 動畫 [layer addAnimation:anim forKey:keyPath]; }]; // 掃除工作 (移除所有儲存的狀態) [[self DR_savedPopAnimationStates] removeAllObjects]; DR_currentAnimationContext = nil; } |
注意老的 model 值被射到了 layer 上,所以在當動畫結束和移除後,model 的值和 presentation 的值是相符合的。
建立像這樣的你自己的 API 不會對沒種情況都很適合,但是如果你需要在你的應用中的很多地方都做同樣的動畫的話,這可以幫助你寫出整潔的程式碼,並減少重複。就算你之後從來不會使用這 種方法,實際做一遍也能幫助你搞懂 UIView block 動畫的 APIs,特別是你已經在 Core Animation 的舒適區的時候,這非常有助於你的提高。
其他的動畫靈感
UIImageView 動畫是一個完全不同的更高層次的動畫 API 的實現方式,我會把它留給你來探索。表面上,它只不過是重新組裝了一個傳統的動畫 API。你所要做的事情就是指定一個圖片陣列和一段時間,然後告訴 image view 開始動畫。在抽象背後,其實是一個新增在 image view 的 layer 上的 contents 屬性的離散的關鍵幀動畫:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
<CAKeyframeAnimation:0x8e5b020; removedOnCompletion = 0; delegate = <_UIImageViewExtendedStorage: 0x8e49230>; duration = 2.5; repeatCount = 2.14748e+09; calculationMode = discrete; values = ( "<CGImage 0x8d6ce80>", "<CGImage 0x8d6d2d0>", "<CGImage 0x8d5cd30>" ); keyPath = contents > |
動畫 APIs 可以以很多不同形式出現,而對於你自己寫的動畫 API 來說,也是這樣的。