APPLE WATCH 的呼吸動效是怎麼實現的?

potato04發表於2019-03-25

本文包含動圖較多,總共大約有10M,移動端請謹慎

本文示例程式碼下載

Apple Watch 第三代釋出的時候,我借健身的理由入手了一個。除了豐富的各種型別運動資料記錄功能外,令我印象深刻的便是定時提醒我呼吸應用裡的那個動畫效果了。本篇文章我將完整地記錄仿製這一動畫的過程,不使用第三方庫。

APPLE WATCH 的呼吸動效是怎麼實現的?
圖1 猜一猜哪個才是官方的動畫?

實現分析

不著急寫程式碼,我們先仔細多觀察幾遍動畫(下載gif)。整朵花由6個圓形花瓣組成,伴隨著花的旋轉,花瓣慢慢由小變大並從合起狀態到完全展開,整個動畫持續時間大約是10秒。不難發現其實動畫一共只有這幾個步驟:

  1. 花瓣變大,花瓣半徑從最小的24pt變大到最終的80pt
  2. 花瓣展開,表現為花瓣圓點從畫布中心向6個方向移動了最大半徑(80pt)的距離
  3. 整體旋轉,整個畫布在花瓣展開過程中旋轉了2π/3弧度

APPLE WATCH 的呼吸動效是怎麼實現的?
圖2 花瓣展開方式

程式碼實現

總體框架

首先我們要確定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()
    }
}
複製程式碼

執行專案看看效果,當然你現在只能看到螢幕中心的一個小白點:

APPLE WATCH 的呼吸動效是怎麼實現的?

圖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的主要原因是動畫開頭和中途的停頓,以及花瓣展開和收回所花的時間是不相等的

再看看效果:

APPLE WATCH 的呼吸動效是怎麼實現的?
圖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]
複製程式碼

APPLE WATCH 的呼吸動效是怎麼實現的?
圖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。而選擇這個初始弧度是為了後續新增顏色考慮。

APPLE WATCH 的呼吸動效是怎麼實現的?
圖6 太棒了,我們的花瓣開了又開

新增顏色

接下來我們給花瓣上顏色,首先我們定義兩個顏色變數,代表第一個和最後一個花瓣的顏色:

/// 第一朵花瓣的顏色
/// 設定好第一朵花瓣和最後一朵花瓣的顏色後,如果花瓣數量大於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
}()
複製程式碼

在上面程式碼中分別設定了containerLayerinstanceColorinstanceRedOffsetinstanceGreenOffsetinstanceBlueOffset,這樣就能使得每個花瓣的顏色根據這些變數呈現出規律變化的顏色。

我一直以為複製出來的例項的顏色RGB各部分是這麼算的:

(source * instanceColor) + instanceXXXOffset //source指被新增到CAReplicatorLayer中的layer的顏色,就是文章中petalLayer的背景色
複製程式碼

實際上是這麼算的:

source * (instanceColor + instanceXXXOffset)
複製程式碼

我感覺這非常彆扭,如果把source設定為firstPetalColor,那instanceColorinstanceXXXOffset得怎麼設定才能最終變化到lastPetalColor?最後我只能將instanceColor設定為firstPetalColorsource設定為白色才解決問題。

APPLE WATCH 的呼吸動效是怎麼實現的?
圖7 這顏色差別有點大啊

是我們顏色或者不透明度選錯了嗎?這並不是主要原因,而是和官方的動畫裡的顏色混合模式不一致導致的。混合模式是什麼?它是指在數字影象編輯中兩個圖層通過混合各自的顏色作為最終色的方法,一般預設的模式都是採用頂層的顏色。通過觀察官方動畫比我們目前的動畫亮許多,經過多種模式對比發現應該是濾色模式iOS中,CALayer有一個compositingFilter屬性,通過它我們可以指定想要的混合模式。

//只要在createPetal()函式中增加這一句即可,指明我們使用濾色混合模式
petalLayer.compositingFilter = "screenBlendMode"
複製程式碼

順便別忘了刪除給花瓣新增不透明度的程式碼,現在我們不需要了:

petalLayer.opacity = 0.75
複製程式碼

APPLE WATCH 的呼吸動效是怎麼實現的?
圖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中,只要關心它的不透明度和大小在什麼時候變化就好了。執行專案,得到最終效果:

APPLE WATCH 的呼吸動效是怎麼實現的?

圖9 呼吸動畫最終效果

總結

本文通過Core Animation實現了 Apple Watch 的呼吸動畫效果。CAReplicatorLayerCAKeyframeAnimation擁有非常強大的建立動畫能力,讓使用者輕鬆簡單即可繪製出複雜的動畫。

資料參考
[1] Geoff Graham,重製Apple Watch呼吸動效, css-tricks.com/recreating-…
[2] Apple, CAReplicatorLayer, developer.apple.com/documentati…
[3] 維基百科,混合模式, en.wikipedia.org/wiki/Blend_…

相關文章