本文包含动图较多, 总共大约有 10M, 移动端请谨慎
本文示例代码下载 https://github.com/potato04/AppleWatchBreathe
Apple Watch 第三代发布的时候, 我借健身的理由入手了一个. 除了丰富的各种类型运动数据记录功能外, 令我印象深刻的便是定时提醒我呼吸应用里的那个动画效果了. 本篇文章我将完整地记录仿制这一动画的过程, 不使用第三方库.
图 1 猜一猜哪个才是官方的动画?
实现分析
不着急写代码, 我们先仔细多观察几遍动画(下载 gif) https://support.apple.com/zh-cn/HT206999 . 整朵花由 6 个圆形花瓣组成, 伴随着花的旋转, 花瓣慢慢由小变大并从合起状态到完全展开, 整个动画持续时间大约是 10 秒. 不难发现其实动画一共只有这几个步骤:
花瓣变大, 花瓣半径从最小的 24pt 变大到最终的 80pt
花瓣展开, 表现为花瓣圆点从画布中心向
6
个方向移动了最大半径 (80pt) 的距离
整体旋转, 整个画布在花瓣展开过程中旋转了 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
- }()
在上面代码中分别设置了 containerLayer 的 instanceColor,instanceRedOffset,instanceGreenOffset,instanceBlueOffset, 这样就能使得每个花瓣的颜色根据这些变量呈现出规律变化的颜色.
我一直以为复制出来的实例的颜色 RGB 各部分是这么算的:
(source * instanceColor) + instanceXXXOffset //source 指被添加到 CAReplicatorLayer 中的 layer 的颜色, 就是文章中 petalLayer 的背景色
实际上是这么算的:
source * (instanceColor + instanceXXXOffset)
我感觉这非常别扭, 如果把 source 设置为 firstPetalColor, 那 instanceColor 和 instanceXXXOffset 得怎么设置才能最终变化到 lastPetalColor? 最后我只能将 instanceColor 设置为 firstPetalColor,source 设置为白色才解决问题.
图 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 的呼吸动画效果. CAReplicatorLayer 和 CAKeyframeAnimation 拥有非常强大的创建动画能力, 让使用者轻松简单即可绘制出复杂的动画.
资料参考
[1] Geoff Graham, 重制 Apple Watch 呼吸动效,
[2] Apple, CAReplicatorLayer,
[3] 维基百科, 混合模式, https://en.wikipedia.org/wiki/Blend_modes
来源: https://juejin.im/post/5c8ef41ef265da682335e688