APPLE WATCH 的呼吸动效是怎么实现的?

本文包含动图较多,总共大约有10M,移动端请谨慎

本文示例代码下载

Apple Watch 第三代发布的时候,我借健身的理由入手了一个。除了丰富的各种类型运动数据记录功能外,令我印象深刻的便是定时提醒我呼吸应用里的那个动画效果了。本篇文章我将完整地记录仿制这一动画的过程,不使用第三方库。

图1 猜一猜哪个才是官方的动画?

实现分析

不着急写代码,我们先仔细多观察几遍动画(下载gif)。整朵花由6个圆形花瓣组成,伴随着花的旋转,花瓣慢慢由小变大并从合起状态到完全展开,整个动画持续时间大约是10秒。不难发现其实动画一共只有这几个步骤:

  1. 花瓣变大,花瓣半径从最小的24pt变大到最终的80pt
  2. 花瓣展开,表现为花瓣圆点从画布中心向6个方向移动了最大半径(80pt)的距离
  3. 整体旋转,整个画布在花瓣展开过程中旋转了2π/3弧度

图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()
    }
}
复制代码

运行项目看看效果,当然你现在只能看到屏幕中心的一个小白点:

图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。而选择这个初始弧度是为了后续添加颜色考虑。

图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设置为白色才解决问题。

图7 这颜色差别有点大啊

是我们颜色或者不透明度选错了吗?这并不是主要原因,而是和官方的动画里的颜色混合模式不一致导致的。混合模式是什么?它是指在数字图像编辑中两个图层通过混合各自的颜色作为最终色的方法,一般默认的模式都是采用顶层的颜色。通过观察官方动画比我们目前的动画亮许多,经过多种模式对比发现应该是滤色模式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 的呼吸动画效果。CAReplicatorLayerCAKeyframeAnimation拥有非常强大的创建动画能力,让使用者轻松简单即可绘制出复杂的动画。

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

猜你喜欢

转载自juejin.im/post/5c8ef41ef265da682335e688