本文包含動圖較多,總共大約有10M,移動端請謹慎
Apple Watch 第三代釋出的時候,我借健身的理由入手了一個。除了豐富的各種型別運動資料記錄功能外,令我印象深刻的便是定時提醒我呼吸應用裡的那個動畫效果了。本篇文章我將完整地記錄仿製這一動畫的過程,不使用第三方庫。
圖1 猜一猜哪個才是官方的動畫?實現分析
不著急寫程式碼,我們先仔細多觀察幾遍動畫(下載gif)。整朵花由6
個圓形花瓣組成,伴隨著花的旋轉,花瓣慢慢由小變大並從合起狀態到完全展開,整個動畫持續時間大約是10秒。不難發現其實動畫一共只有這幾個步驟:
- 花瓣變大,花瓣半徑從最小的
24pt
變大到最終的80pt
- 花瓣展開,表現為花瓣圓點從畫布中心向
6
個方向移動了最大半徑(80pt)
的距離 - 整體旋轉,整個畫布在花瓣展開過程中旋轉了
2π/3
弧度
程式碼實現
總體框架
首先我們要確定6個花瓣該如何繪製,最簡單辦法當然是新增6個子Layer
來畫圓,然後依次給它們新增動畫效果...等等,這6個圓中心對稱,而且動畫套路一樣...如果你之前熟悉框架自帶的各種CALayer
常用子類,你肯定已經想到了CAReplicatorLayer,它可以依據你預設的圖層和配置快速高效地複製出數個幾何、時間、顏色規律變換的圖層。那麼我們就可以開始自定義檢視BreatheView
:
class BreathView: UIView {
/// 花瓣數量
var petalCount = 6
/// 花瓣最大半徑
var petalMaxRadius: CGFloat = 80
/// 花瓣最小半徑
var petalMinRadius: CGFloat = 24
/// 動畫總時間
var animationDuration: Double = 10.5
/// 花瓣容器圖層
lazy private var containerLayer: CAReplicatorLayer = {
var containerLayer = CAReplicatorLayer()
//指明覆制的例項數量
containerLayer.instanceCount = petalCount
//這裡是關鍵,指定每個"複製"出來的layer的幾何變換,這裡是按Z軸逆時針旋轉 2π/6 弧度
containerLayer.instanceTransform = CATransform3DMakeRotation(-CGFloat.pi * 2 / CGFloat(petalCount), 0, 0, 1)
return containerLayer
}()
//以下為相關初始化方法
override init(frame: CGRect) {
super.init(frame: frame)
setupView()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setupView()
}
private func setupView() {
backgroundColor = UIColor.black
layer.addSublayer(containerLayer)
}
override func layoutSubviews() {
super.layoutSubviews()
containerLayer.frame = bounds
}
}
複製程式碼
接下來建立函式createPetal
,它根據引數花瓣中心點
和半徑
返回一個CAShapeLayer
的花瓣:
private func createPetal(center: CGPoint, radius: CGFloat) -> CAShapeLayer {
let petal = CAShapeLayer()
petal.fillColor = UIColor.white.cgColor
let petalPath = UIBezierPath(arcCenter: center, radius: radius, startAngle: 0.0, endAngle: CGFloat(2 * Float.pi), clockwise: true)
petal.path = petalPath.cgPath
petal.frame = CGRect(x: 0, y: 0, width: containerLayer.bounds.width, height: containerLayer.bounds.height)
return petal
}
複製程式碼
新建函式animate()
,呼叫這個方法就啟動動畫:
func animate() {
//呼叫createPetal獲取花瓣
let petalLayer = createPetal(center: CGPoint(x: containerLayer.bounds.width / 2, y: containerLayer.bounds.height / 2), radius: petalMinRadius)
//新增到containerLayer中
containerLayer.addSublayer(petalLayer)
}
複製程式碼
最後在ViewController
中例項化BreathView
並新增到檢視中, 然後讓它顯示在螢幕上的時候就開始動畫:
class ViewController: UIViewController {
let breatheView = BreathView(frame: CGRect.zero)
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view, typically from a nib.
view.addSubview(breatheView)
}
override func viewDidLayoutSubviews() {
breatheView.frame = view.bounds
}
override func viewDidAppear(_ animated: Bool) {
breatheView.animate()
}
}
複製程式碼
執行專案看看效果,當然你現在只能看到螢幕中心的一個小白點:
圖3 我們的進度很快,主體框架已經搭建完成。接下來開始我們的第一個動畫吧。
展開花瓣
前面提到過,花瓣展開是各自向6個方向移動了petalMaxRadius
距離。藉助ReplicatorLayer
的特性,程式碼可以非常簡單:
//為了看清6個花瓣堆疊的樣子,暫時設定0.75的不透明度
petalLayer.opacity = 0.75
//定義展開的關鍵幀動畫
let moveAnimation = CAKeyframeAnimation(keyPath: "position.x")
//values和keyTimes一一對應,各個時刻的屬性值
moveAnimation.values = [petalLayer.position.x,
petalLayer.position.x - petalMaxRadius,
petalLayer.position.x - petalMaxRadius,
petalLayer.position.x]
moveAnimation.keyTimes = [0.1, 0.4, 0.5, 0.95]
//定義CAAnimationGroup,組合多個動畫同時執行。這不待會還有一個"放大花瓣"嘛
let petalAnimationGroup = CAAnimationGroup()
petalAnimationGroup.duration = animationDuration
petalAnimationGroup.repeatCount = .infinity
petalAnimationGroup.animations = [moveAnimation]
petalLayer.add(petalAnimationGroup, forKey: nil)
複製程式碼
這裡用
CAKeyframeAnimation
的主要原因是動畫開頭和中途的停頓,以及花瓣展開和收回所花的時間是不相等的
再看看效果:
圖4 花瓣展開的過程中沒有放大導致有點偏差放大花瓣
熟悉了前面的過程,新增放大效果就很簡單了:
let scaleAnimation = CAKeyframeAnimation(keyPath: "transform.scale")
scaleAnimation.values = [1, petalMaxRadius/petalMinRadius, petalMaxRadius/petalMinRadius, 1]
scaleAnimation.keyTimes = [0.1, 0.4, 0.5, 0.95]
...
//別忘了將 scaleAnimation 新增到動畫組中
petalAnimationGroup.animations = [moveAnimation, scaleAnimation]
複製程式碼
圖5 花瓣展開現在正常了
旋轉花瓣
旋轉花瓣是通過畫布整體旋轉實現而不是花瓣本身,也就是現在需要給containerlayer
新增動畫:
let rotateAnimation = CAKeyframeAnimation(keyPath: "transform.rotation")
rotateAnimation.duration = animationDuration
rotateAnimation.values = [-CGFloat.pi * 2 / CGFloat(petalCount),
-CGFloat.pi * 2 / CGFloat(petalCount),
CGFloat.pi * 2 / CGFloat(petalCount),
CGFloat.pi * 2 / CGFloat(petalCount),
-CGFloat.pi * 2 / CGFloat(petalCount)]
rotateAnimation.keyTimes = [0, 0.1, 0.4, 0.5, 0.95]
rotateAnimation.repeatCount = .infinity
containerLayer.add(rotateAnimation, forKey: nil)
複製程式碼
從初始弧度-CGFloat.pi * 2 / CGFloat(petalCount)
旋轉到CGFloat.pi * 2 / CGFloat(petalCount)
,正好旋轉了2π/3
。而選擇這個初始弧度是為了後續新增顏色考慮。
新增顏色
接下來我們給花瓣上顏色,首先我們定義兩個顏色變數,代表第一個和最後一個花瓣的顏色:
/// 第一朵花瓣的顏色
/// 設定好第一朵花瓣和最後一朵花瓣的顏色後,如果花瓣數量大於2,那麼中間花瓣的顏色將根據這兩個顏色蘋果進行平均過渡
var firstPetalColor: (red: Float, green: Float, blue: Float, alhpa: Float) = (0.17, 0.59, 0.60, 1)
/// 最後一朵花瓣的顏色
var lastPetalColor: (red: Float, green: Float, blue: Float, alhpa: Float) = (0.31, 0.85, 0.62, 1)
複製程式碼
為什麼這兩個變數的型別不是
UIColor
?因為接下來要根據兩個顏色的RGB
算出instanceXXXOffset
,為了演示專案簡單才這麼處理。不過實際專案中建議使用UIColor
,雖然增加了一些程式碼反算RGB
的值,但是可以讓BreathView
的使用者避免困惑
然後更新containerLayer
:
lazy private var containerLayer: CAReplicatorLayer = {
var containerLayer = CAReplicatorLayer()
containerLayer.instanceCount = petalCount
///新增程式碼---start---
containerLayer.instanceColor = UIColor(red: CGFloat(firstPetalColor.red), green: CGFloat(firstPetalColor.green), blue: CGFloat(firstPetalColor.blue), alpha: CGFloat(firstPetalColor.alpha)).cgColor
containerLayer.instanceRedOffset = (lastPetalColor.red - firstPetalColor.red) / Float(petalCount)
containerLayer.instanceGreenOffset = (lastPetalColor.green - firstPetalColor.green) / Float(petalCount)
containerLayer.instanceBlueOffset = (lastPetalColor.blue - firstPetalColor.blue) / Float(petalCount)
///新增程式碼----end----
containerLayer.instanceTransform = CATransform3DMakeRotation(-CGFloat.pi * 2 / CGFloat(petalCount), 0, 0, 1)
return containerLayer
}()
複製程式碼
在上面程式碼中分別設定了containerLayer
的instanceColor
、instanceRedOffset
、instanceGreenOffset
、instanceBlueOffset
,這樣就能使得每個花瓣的顏色根據這些變數呈現出規律變化的顏色。
我一直以為複製出來的例項的顏色RGB
各部分是這麼算的:
(source * instanceColor) + instanceXXXOffset //source指被新增到CAReplicatorLayer中的layer的顏色,就是文章中petalLayer的背景色
複製程式碼
實際上是這麼算的:
source * (instanceColor + instanceXXXOffset)
複製程式碼
我感覺這非常彆扭,如果把source
設定為firstPetalColor
,那instanceColor
和instanceXXXOffset
得怎麼設定才能最終變化到lastPetalColor
?最後我只能將instanceColor
設定為firstPetalColor
,source
設定為白色才解決問題。
是我們顏色或者不透明度選錯了嗎?這並不是主要原因,而是和官方的動畫裡的顏色混合模式不一致導致的。混合模式是什麼?它是指在數字影象編輯中兩個圖層通過混合各自的顏色作為最終色的方法,一般預設的模式都是採用頂層的顏色。通過觀察官方動畫比我們目前的動畫亮許多,經過多種模式對比發現應該是濾色模式
。iOS
中,CALayer
有一個compositingFilter屬性,通過它我們可以指定想要的混合模式。
//只要在createPetal()函式中增加這一句即可,指明我們使用濾色混合模式
petalLayer.compositingFilter = "screenBlendMode"
複製程式碼
順便別忘了刪除給花瓣新增不透明度的程式碼,現在我們不需要了:
petalLayer.opacity = 0.75
複製程式碼
圖8 濾色混合模式使得畫面更加明亮
畫龍點睛
我們的動畫還沒有結束,因為還有花瓣收回的時候有一個殘影效果。經過前面動畫繪製,相信你已經明白該怎麼做了!繼續修改我們的animate()
函式:
let ghostPetalLayer = createPetal(center: CGPoint(x: containerLayer.bounds.width / 2 - petalMaxRadius, y: containerLayer.bounds.height / 2), radius: petalMaxRadius)
containerLayer.addSublayer(ghostPetalLayer)
ghostPetalLayer.opacity = 0.0
let fadeOutAnimation = CAKeyframeAnimation(keyPath: "opacity")
fadeOutAnimation.values = [0, 0.3, 0.0]
fadeOutAnimation.keyTimes = [0.45, 0.5, 0.8]
let ghostScaleAnimation = CAKeyframeAnimation(keyPath: "transform.scale")
ghostScaleAnimation.values = [1.0, 1.0, 0.78]
ghostScaleAnimation.keyTimes = [0.0, 0.5, 0.8]
let ghostAnimationGroup = CAAnimationGroup()
ghostAnimationGroup.duration = animationDuration
ghostAnimationGroup.repeatCount = .infinity
ghostAnimationGroup.animations = [fadeOutAnimation, ghostScaleAnimation]
ghostPetalLayer.add(ghostAnimationGroup, forKey: nil)
複製程式碼
我們建立了一個花瓣影子同樣也可以放到已經配置好的containerLayer
中,只要關心它的不透明度和大小在什麼時候變化就好了。執行專案,得到最終效果:
圖9 呼吸動畫最終效果
總結
本文通過Core Animation
實現了 Apple Watch 的呼吸動畫效果。CAReplicatorLayer
和CAKeyframeAnimation
擁有非常強大的建立動畫能力,讓使用者輕鬆簡單即可繪製出複雜的動畫。
資料參考
[1] Geoff Graham,重製Apple Watch呼吸動效, css-tricks.com/recreating-…
[2] Apple, CAReplicatorLayer, developer.apple.com/documentati…
[3] 維基百科,混合模式, en.wikipedia.org/wiki/Blend_…