蘋果中的動畫采用的是 "按需求播放" 這樣的形式, 即不需要自己計算許多參數, 只需要提供如何動畫的要求, 系統自動去計算相關的參數.
需要將動畫看作是用戶交互的一種反饋或提示, 而不是簡單的效果而已.
1 繪圖, 動畫和線程
繪圖和動畫是相輔相成的, 即當提供繪制指令后, 系統并不立即執行, 而是等到一個繪制時機統一執行, 這個時機稱為 redraw moment.
動畫和繪圖的執行是一個道理. 動畫擁有幀(frame)的概念, 即動畫是由一張一張的幀組成的.
蘋果將執行動畫的系統組件稱為 animation server.
動畫是在用戶和真實的屏幕顯示中間插入了一段"電影"畫面, 當動畫結束后, 這個電影畫面也就從屏幕上移除了, 然后恢復真實屏幕的顯示. 但用戶不會察覺到這點, 因為當動畫結束后, 真實屏幕上的繪制也會變為和動畫結束時的狀態一樣, 但結束狀態需要程序員來保證其正確性.
一個簡單 view 動畫流程如下:
- 將 view 由位置 1 移動到位置 2, 由于沒有到 redraw 時機, 故現在屏幕沒有任何變化.
- 提出一個動畫請求, 動畫的內容是 view 從位置 1 移動到位置 2. 由于沒有到 redraw 時機, 故屏幕也沒有任何變化.
- 系統將所有代碼執行完畢后, 出現空閑時機, 即 redraw 時機.
- 在 redraw 時機后, 系統將動畫進行播放.
- 動畫結束后, view 也和動畫的最后一幀的狀態一致.
需要對動畫過程有正確認識: 動畫只是在真實屏幕上的一層"電影"效果. 不過實際上并不是真的存在"電影"效果, 只是為了方便理解.
真實的情況是, 在進行動畫時, 并非現有的 layer 在進行動畫, 而是單獨的一個 presentation layer
, 在這個圖層上顯示動畫的每一幀效果.
layer 的 presentation layer 可以通 presentation
方法來訪問, 而 presentation layer 對應的 layer 可以通過 presentation layer 的 model
屬性來獲取.
另外 Animation server 組件是自動在單獨線程執行的, 所以不用擔心線程管理問題. 但需要對自己的界面終態進行操作, 以符合動畫終態.
在動畫結束事件到達時, 可以在對應的方法中去開始下一個動畫, 或者是去做一些清理工作.
2 ImageView 和 Image 動畫
繪圖也是首先講的 Image 繪制, 這里就先來看 Image 的動畫.
給 UIImageView 的 animationImages
或 highlightedAnimationImages
屬性設置一個 UIImage 數組, 這個數組代表的就是動畫的每一幀, 然后向 UIImageView 發送 startAnimating
消息, 它就開始動畫. 動畫時根據 animationDuration
的設置來決定如何計算幀的出現時間. 默認情況下動畫是無限循環的, 可以通過 animationRepeatCount
來設置循環次數. 還可通過 stopAnimating
消息來結束動畫. 在動畫開始前和結束后, 它都是在顯示 image
屬性或 highlightedImage
屬性對應的圖片.
有一個技巧就是通過圖片上下文繪制若干張圖片保存到數組中, 然后將這個數組賦值給 UIImageView 的動畫圖片數組, 讓 UIImageView 來進行動畫.
3 View 動畫
所有的動畫本質上都是圖層動畫. 只是在系統中為 UIView 提供了一些屬性, 可以方便直接通過 UIView 進行動畫.
對 View 進行動畫的方式目前有三種:
- 開始--提交方式: 很少使用
- 動畫塊方式: 當前最常用, 除了需要重復進行固定次數循環動畫的情況.
- 屬性動畫器: iOS 10 之后新增. 它不是來替代動畫塊的, 而是對動畫塊的擴展和補充. 這個應該會在未來慢慢推廣使用.
下面就來看一些動畫基礎.
對于 View 而言, 在動畫塊包裹中的所有內容, 只要是對可動畫屬性的修改, 就會生成動畫. 并且由于是直接在操作諸如位置等屬性, 故視圖的終態就和動畫終態是一致的. 但這里有一個問題, 如果使用約束的話, 約束沒有改變, 則在未來的任何時候, 如果重新布局, 則界面中的狀態會回到初態, 所以約束布局的情況下需要單獨在動畫時對約束進行處理, 即重新定義約束后, 再在動畫塊中調用 view 的 layoutIfNeeded
方法.
如果想要動畫塊中的某些可動畫屬性改變不會計入動畫, 則可以調用 UIView 的類方法 performWithoutAnimation
, 將這些代碼寫到其中即可.
當進行重復動畫時, 指定重復次數需要一些技巧. 默認情況下 UIView 的動畫如果指定重復選項, 動畫是永遠重復的.
不過可以通過在動畫塊中通過 UIView 的類方法 setAnimationRepeatCount
設置次數. 即不指定 repeat 選項的情況下使用這個方法來指定重復次數.
彈性動畫的兩個參數:
- Damping ratio: 取值0到1, 描述的是最終的震蕩效果, 值越小, 最終晃動越大, 0.8 是比較合適的數值.
- Initial velocity: 初速度, 值越大的話, 則動畫將要結束時, 到達終點后偏離終點的距離越大. 一般設置為0, 看需要什么效果來定.
4 取消 View 動畫
動畫在進行過程中如何停止動畫?
在 iOS 10 引入屬性動畫器之前, 往往都是將動畫從圖層中移除的方式來停止動畫.
但這個方法的缺點很明顯: 移除動畫后, 正在進行的動畫就生硬地沒了, 視圖也直接到達終態.
故取消動畫的一個正確做法(或者說是普遍做法)是讓動畫加速行進至終態. 這正是動畫疊加的絕佳應用場合.
但之前的動畫是在行進過程中, 只有先將所有動畫取消, 但這樣會導致視圖直接處于終態. 如果可以獲取到在取消時刻的動畫狀態, 然后取消之前動畫, 再插入一個新的加速動畫, 則可以將這個問題解決.
而上述方法的核心就是利用 presentation layer, 將視圖在取消時刻的狀態和當前 presentation layer 的狀態設置為一致, 然后再開始新加速后的動畫.
流程即:
- 獲取視圖根圖層的 presentation layer 的當前某個需要的狀態, 然后將其賦值給視圖的根圖層.
- 將視圖動畫(在根圖層上的動畫)取消掉.
- 新建一個動畫, 該動畫的終態是原來的終態, 但時間縮短很多.
- 如果動畫取消的上下文意味著動畫回到原來位置, 則將這個動畫的終態設置為視圖初始狀態即可.
- 另外如果取消在當前的意思是停在當前位置, 則就不加新建的這個動畫.)
這樣的效果就可以很平滑了.
private func cancelSprintAnimation() {
let presentationLayer = self.aView.layer.presentation()!
// 將進行動畫的視圖狀態賦值為其 presentationLayer 的當前狀態, 這里是 position
self.aView.layer.position = presentationLayer.position
// 移除之前的動畫
self.aView.layer.removeAllAnimations()
print(aView.center)
// 開始新的加速動畫, 這里是加速到最終位置.
UIView.animate(withDuration: 0.1) {
self.aView.center.y = self.view.bounds.height / 2.0
}
}
private func performSpringAnimation() {
print("進行彈性動畫")
UIView.animate(withDuration: 2.0, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0, options: [], animations: {
self.aView.center.y = 450
})
}
上面設置 layer 的屬性為 presentation layer 的當前屬性, 因為動畫實際是 presentation layer 在進行的.
取消 repeat 動畫
重復動畫取消的話, 也是采用加速到終態或加速回到初態. 或保持在當前狀態.
因為重復動畫不會被其他動畫疊加, 所以在重復動畫上添加動畫實際就自動把重復動畫取消了. 然后利用 beginFromCurrentState
選項, 即可做到之前的平滑取消動畫.
private func performRepeatAnimation() {
UIView.animate(withDuration: 2.0, delay: 0, options: [.repeat, .autoreverse], animations: {
self.aView.center.y = 600
}, completion: nil)
}
private func cancelRepeatAnimation() {// 取消重復動畫
UIView.animate(withDuration: 0.1, delay: 0, options: [.beginFromCurrentState], animations: {
self.aView.center.y = self.view.frame.height / 2.0
}, completion: nil)
}
取消重復動畫時, 如果下一個加速動畫的終態是和重復終態相同的話, 則有 bug... 取消動畫沒有被執行, 故需要加速動畫的終態不和重復終態一樣, 比如下面的本來需要 600, 這里設置 600.000001:
private func cancelRepeatAnimation() {
UIView.animate(withDuration: 0.1, delay: 0, options: [.beginFromCurrentState], animations: {
self.aView.center.y = 600.000001
}, completion: nil)
}
如果想要的效果是在當前位置停下, 則也是獲取 presentation layer 的當前參數, 然后在動畫塊中賦值給 layer 即可.
可以使用一個視圖屬性將若干可動畫屬性進行改變, 然后進行動畫, 這個先了解一下.
5 幀動畫
原理都一樣, 不過可以通過幀動畫來組裝一些復雜的動畫效果.
6 轉變動畫
transition 動畫表示的是視圖內容的改變, 有兩種:
- transition(with:duration:options:animations:completion:): 對一個視圖的內容改變進行動畫, 提供的選項詳見文檔.
- transition(from:to:duration:options:completion:): 將 fromView 替換為 toView.
7 隱式 Layer 動畫
只有當 Layer 布局并顯示到界面上后, 改變它的可動畫屬性才會出現動畫. 另外根 layer 只能進行顯式動畫.
layer 的隱式動畫都是在 CATransaction 的上下文中執行的. 另外總是有一個不可見的 CATransaction "包圍著"代碼, 故可以不用調用 CATransaction 的 begin 和 commit 也能改變動畫的一些屬性, 比如:
CATransaction.setAnimationDuration(0.8)
arrow.transform = CATransform3DRotate(arrow.transform, .pi/4.0, 0, 0, 1)
8 核心動畫
下面正式進入 Core Animation 的內容.
CA 動畫就是指的顯式圖層動畫, 主要使用 CAAnimation 和它的子類來實現動畫.
在使用 CA 進行動畫時, 視圖的終態不會自動設置, 需要手動設. 不然視圖在動畫完成后會回到原位置, 因為動畫是在 layer 上進行的.
8.1 CABasicAnimation
CA 的使用方式是: 創建動畫對象(CAAnimation子類型), 然后將它添加到 layer 上即可. 添加時需要一個 key, 這個key用于標志唯一的動畫.
添加了動畫對象后, layer 就開始動畫(redraw 時機), 但動畫結束后被移除掉, 此時 layer 的狀態又會恢復到動畫前, 故需要手動修改終態.
使用 CA 動畫的基本模式是:
- 獲取 layer 的某個當前動畫對應的屬性的初始值和終值.
- 將 layer 的屬性改變為終值, 如果不想隱式動畫起作用, 需要調用
setDisableActions(true)
. - 創建 CA 動畫, 在其中指定需要動畫的屬性
- 將動畫添加到 layer 上.
顯式動畫被添加到 layer 時, 實際添加的是它的不可變副本.
8.2 CAKeyFrameAnimation
這個是圖層幀動畫類
8.3 CASpringAnimation
這個是圖層彈性動畫類.
9 動畫組
可以把一組動畫通過 CAAnimationGroup
組合在一起, 每個單獨的動畫都添加到它的 animation
屬性中, 通過動畫的延遲和時長來決定動畫的執行順序, 從而可以完成許多復雜的效果.
CAAnimationGroup
本身就是 CAAnimation 的子類, 可以把它當成是一個父動畫, 可以在其中添加子動畫. 其中的子動畫會繼承它的一些默認屬性值(如果子動畫沒有設置的話).
下面來實現一個動船的動畫:
第一個動畫: 圖層沿指定曲線路徑運動.
func createAnim1() -> CAAnimation {
let areaHeight: CGFloat = 200
let verticalSpace: CGFloat = 7
let path = CGMutablePath()
var leftright: CGFloat = 1 // 表示現在是朝左還是朝右, 左為 -右為 1.
// 以下代碼生成需要的路徑
var next: CGPoint = self.view.layer.position // 路徑起點
var pos: CGPoint
path.move(to: CGPoint(next.x, next.y))
for _ in 0 ..< 4 {
pos = next
leftright *= -1
next = CGPoint(pos.x + areaHeight * leftright, pos.y verticalSpace)
path.addCurve(to: CGPoint(next.x, next.y),
control1: CGPoint(pos.x, pos.y + 30),
control2: CGPoint(next.x, next.y - 30))
}
endPoint = next // 記錄最終點的位置.
// 動畫1: 將圖層的 position 沿著曲線運動. 這個動畫添加到任何圖層上都適用.
let anim1 = CAKeyframeAnimation(keyPath: #keyPa(CALayer.position))
anim1.path = path
anim1.calculationMode = kCAAnimationPaced
return anim1
}
第二個動畫: 船需要在轉彎的時候同時翻轉, 否則看起來就不正常了. 翻轉時直接沿著 Y 軸旋轉即可. 第二個動畫需要和第一個動畫配合, 當第一個動畫中小船每次處于曲線的頂點時, 都需要對其進行翻轉, 如果遇到比較復雜的情況, 則需要定義 keyTimes
數組, 讓兩個動畫可以協作.
func createAnim2() -> CAAnimation {
let revs = [0.0, .pi, 0.0, .pi]
let anim2 = CAKeyframeAnimation(keyPath:#keyPath(CALayer.transform))
anim2.values = revs
anim2.valueFunction = CAValueFunction(name:kCAValueFunctionRotateY)
anim2.calculationMode = kCAAnimationDiscrete
return anim2
}
第三個動畫: 小船的重復震動效果, 模擬的是風雨飄搖.
func createAnim3() -> CAAnimation {
let pitches = [0.0, .pi/60.0, 0.0, -.pi/60.0, 0.0]
let anim3 = CAKeyframeAnimation(keyPath:#keyPath(CALayer.transform))
anim3.values = pitches
anim3.repeatCount = .infinity
anim3.duration = 0.5
anim3.isAdditive = true
anim3.valueFunction = CAValueFunction(name:kCAValueFunctionRotateZ)
return anim3
}
最后通過動畫組將三個動畫組合, 然后將動畫組應用到小船圖層上.
private func addAnimToBoatImageViewLayer() {
let animGroup = createAnimGroup()
boatImageView.layer.add(animGroup, forKey: nil)
CATransaction.setDisableActions(true)
boatImageView.layer.position = endPoint ?? .zero
}
利用 CAAnimationGroup 可以實現許多復雜的動畫效果, 這里看到的小船動畫就是一個.
10 關于動畫凍結
可以不把動畫取消掉, 而是在某個位置將動畫凍結, 這樣在未來的某個時候, 可以手動繼續開始動畫.
由于 CALayer 有一個 speed 屬性, 如果將它改為 0, 就可以把動畫凍結. 另外還有一個 timeOffset 屬性, 可以控制顯示動畫的任意一幀. 這兩個屬性結合后, 就可以實現動畫按 timeOffset 的值來動態控制顯示了.
func increase() {
guard shape.timeOffset + 0.1 <= 1 else { return }
shape.timeOffset += 0.1
}
func decrease() {
guard shape.timeOffset - 0.1 >= 0 else { return }
shape.timeOffset -= 0.1
}
func setupAnims() {
shape.frame = bounds
layer.addSublayer(shape)
shape.fillColor = UIColor.clear.cgColor
shape.strokeColor = UIColor.red.cgColor
let path = CGPath(ellipseIn: CGRect(10, 10, 50, 50), transform: nil)
shape.path = path
let path2 = CGPath(rect: CGRect(10, 10, 50, 50), transform: nil)
let basicAnim = CABasicAnimation(keyPath: #keyPath(CAShapeLayer.path))
basicAnim.duration = 1.0
basicAnim.fromValue = path
basicAnim.toValue = path2
shape.speed = 0
shape.timeOffset = 0
shape.add(basicAnim, forKey: nil)
}
11 關于圖層的轉變
圖層的轉變(transition) 指的是被轉變的圖層擁有兩個"拷貝", 通過第二個替換第一個, 從而實現一些轉變的效果.
主要是設置轉變的類型和子類型來達到效果, 這里由于歷史原因, 轉變中的 Bottom 和 Top 正好和手機方向是相反的.
private func rootLayerTransition() {
let transition = CATransition()
transition.type = kCATransitionPush
transition.subtype = kCATransitionFromBottom
transition.duration = 2.0
CATransaction.setDisableActions(true)
layer.contents = UIImage(named: "img_highlighted")?.cgImage
layer.add(transition, forKey: nil)
}
這樣的動畫應用場景主要是父圖層的 maskToBounds 屬性是 true, 然后子圖層從范圍外移動到范圍內的情況, 這樣就可以達到動畫效果, 且在父圖層外不會看大子圖層的移動效果.
12 動畫列表
為了了解動畫的內部原理, 需要首先看看什么是 Animation List.
顯式動畫是通過 CALayer 的 add
方法添加到圖層上的. 動畫對象(CAAnimation)改變的是圖層的繪制方式. 當動畫添加到圖層上后, 剩下的工作都是由圖層的繪制機制來完成的.
在圖層中維護了一張當前正在或需要進行的動畫的列表, 動畫通過 add
方法添加到列表中, 當動畫時機到達時, 圖層就根據動畫列表中的所有動畫來決定如何將自己繪制出來, 且按照一定的順序進行, 而繪制時候要進行的任務在文檔中稱為 rendering tree.
動畫的添加順序就是它們在繪制時候的執行順序.
動畫列表中如果存在某個 key, 當另外一個擁有相同 key 的動畫添加進來時, 之前的相同 key 的動畫會被移除掉.
如果添加動畫時指定 key 為 nil, 則不受 key 值的影響, 即可以多次添加 nil key 的動畫.
但是蘋果對 keyPath 和 key 搞了一個小動作, 即如果在創建動畫時將 keyPath 設置為 nil, 則動畫默認指向的屬性就是 add
方法中 key 對應的屬性, 這個不得不說是個蛋疼的地方. 有時這樣的結合規則會被誤用.
所以一定要清楚地認識這兩個方法參數的作用:
- 創建動畫時一定要指定動畫的 keyPath
- 添加動畫時候的 key 一定只是作為不同動畫的標記
這樣的話, 在某些情況下就可以通過相同 key 的動畫將之前的那個替換掉.
另外 CATransition 添加的動畫的 key 一直是 "transition"(也就是 kCATransition
代表的字符串), 故同一個 layer 同時只能添加一個 CATransition 動畫.
如果沒有特殊處理的話, 當動畫結束后, 動畫就會從動畫列表中移除掉. 當然可以設置動畫的 isRemovedOnCompletion
來將它保留在動畫列表中, 下次動畫時機時就會再次被執行.
這里有一個經典的錯誤實現:
很多例子都是使用
isRemovedOnCompletion
結合 fillMode 設置來把圖層動畫的最終狀態保留在動畫的最后狀態上, 但這樣的錯誤的, 因為這個時候的圖層只是 "看起來" 在最終位置.而正確的解決辦法是: 將圖層對應的屬性修改為動畫的終態一致, 然后進行動畫即可, 而非設置 fillMode.
而 kCAFillMode 的用途是在動畫組(Animation Group)中, 和子動畫相關的.
在代碼中無法直接訪問動畫列表的所有動畫, 只能利用 animation(forKey:)
方法來獲取某個 key 對應的動畫. 且動畫完成回調調用時, 該動畫已經完成, 故就已經從動畫列表中移除了, 所以在完成回調中, 是獲取不到該動畫的.
可以使用 removeAnimation(forKey:)
或者 removeAllAnimations
移除動畫, 當移除 nil key 的動畫時, 只有通過 removeAllAnimations
才可以辦到.
如果 APP 被暫停(suspended)的時候, 系統會自動在所有圖層上調用 removeAllAnimations
方法.
當手動將進行中的動畫被移除時, 它會直接停止. 但停止的時機是在下一個重繪時機到達時. 如果想直接停止動畫, 需要在 transaction 塊中寫.
待續...