UI 开发过程中, 时常会遇到实现一些复杂动画的需求, 借着这次机会, 来系统重温 iOS 动画的实现和一些相关概念, 最后实现一个复杂的动画效果. 在梳理过程中, 主要参考的是 iOS Animation by Tutorial
一书.
看完了书, 说句老实话, 动画实现的话是需要去写代码和练习的, 同时添加自己的一些创意在其中, 是非常有意思的一块内容.
整体梳理 在 iOS 平台上, 动画在实现上可以分为如下几种:
View 动画 View 动画主要使用的是一系列 animate
开头的 UIView
类方法, 比如 UIView.animate(withDuration: animations:)
, 在动画块 animations
中针对某个视图的属性进行改变, 从而驱动动画执行.
可动画属性 只有可动画属性的改变才能触发动画, 如下是一些可动画属性:
位置和尺寸: bounds
, frame
, center
以及它们内部的一些属性, 比如 x
, width
等.
外观: backgroundColor
, alpha
等
变形: transform
及其相关属性.
动画选项 动画选项主要是用来控制动画的执行, 有如下的选项:
Repeat: 动画的重复, 比如 repeat
, autoreverse
等, 通过 [.repeat, .autoreverse]
这样的方式可以将多个选项组合起来使用.
easing(缓和): 用于控制动画随时间变化的速率, 比如 curveEaseIn
, curveEaseOut
等.
弹性(Spring) 使用 UIView.animate(withDuration:delay:usingSpringWithDamping:initialSpringVelocity:options:animations:completion:)
这个 API 在动画的末端可以附加一些弹性效果.
转换(Transition) 这些转换动画都是预定义的, 通过 UIView.transition
接口使用.
一共有如下的预定义转换动画的选项/类型:
1 2 3 4 5 6 7 public static var transitionFlipFromLeft: UIView .AnimationOptions { get }public static var transitionFlipFromRight: UIView .AnimationOptions { get }public static var transitionCurlUp: UIView .AnimationOptions { get }public static var transitionCurlDown: UIView .AnimationOptions { get }public static var transitionCrossDissolve: UIView .AnimationOptions { get }public static var transitionFlipFromTop: UIView .AnimationOptions { get }public static var transitionFlipFromBottom: UIView .AnimationOptions { get }
关键帧动画(Key-Frame) 有时候需要把多个动画连续执行, 如果使用 UIView.animate
一类的接口, 只有在 completion
块中串联下一个动画, 这样并非好的实现.
iOS 中提供了一类实现方式, 即把一个复杂动画的若干组成进行分割, 形成多个不同的过程, 这些过程就是 KeyFrame, 然后将这些单个的 KeyFrame 重新组合形成一个 KeyFrame 动画.
通过 UIView.animateKeyframes
接口来组合 KeyFrame 动画, 在它的 animations
块中调用 UIView.addKeyframe
添加关键帧. 添加时指定相对开始时刻, 指定时长, 即可让多个关键帧动画自由组合, 实现复杂动画效果. 比如要让一个视图变上升边变大, 则可以将这两个关键帧的时刻和时长重叠, 这样就可以达到组合的效果.
约束动画 约束动画指使用新约束替换现有的约束或改变约束的某些可变属性, 从而让约束状态更新触发动画.
过程是这样的: 替换或更新约束后, 在 UIView.animate
动画块中调用 view.layoutIfNeeded()
触发视图布局计算, 通过视图属性的改变, 从而触发动画.
图层动画 图层动画主要使用 Core Animation 库中的 API, 这些 API 更加底层, 同时也更加强大.
首先要明白 View 和 Layer 的区别.
Layer 实际是一个存放视图数据的容器, 每个 View 背后都有一个 Layer 作为支撑. Layer 只是一种数据容器, 它里面不保存复杂的自动布局依赖关系, 也不处理用户输入. 在其中包含许多可视属性, 这些属性最终作为渲染图层对应图像的依据.
Core Animation 拥有缓存 layer 内容的能力, 并且可以直接使用 GPU 进行快速绘制.
View 有如下特点:
视图上包含有复杂的布局信息, 视图关系(父子)信息.
视图负责处理用户输入
拥有自定义的逻辑或动画, 这些代码在主线程通过 CPU 进行.
有许许多多不同的视图类.
Layer 有如下特点:
只有简单的层级结构, 可以快速计算布局, 快速进行渲染.
不处理用户输入, 也就没有响应链的开销.
默认没有自定义的逻辑代码, 并且直接通过 GPU 绘制
至少少数几种 Layer 类.
图层动画在使用上和视图动画类似, 也是改变可动画属性, 指定时长, 然后让 CA 框架去计算和呈现动画. 不同之处在于, layer 有比视图多得多的可动画属性.
图层动画初步 列举一些图层的可动画属性:
CALayer 的子类还有可能有更多的可动画属性.
实现图层动画时, 使用 CABasicAnimation
等动画数据容器来指定动画的一些属性, 这些 Animation 对象只是动画的数据容器, 描述动画的执行. 因为这些动画对象不会被绑定到任何特定的 layer 上, 故可以非常方便地重用.
图层动画的简单使用 使用如下方式创建动画对象:
1 2 3 4 5 6 7 private func constructAnimation (size : CGSize ) -> CAAnimation { let anim = CABasicAnimation (keyPath: "position.x" ) anim.fromValue = - size.width / 2.0 anim.toValue = size.width / 2.0 anim.duration = 0.5 return anim }
要把这个动画添加到某个图层上, 只需要调用如下代码:
1 2 let anim = constructAnimation(size: view.bounds.size) view.layer.add(anim, forKey: "用于识别动画, 并进行后续操作" )
add
方法会添加该动画的拷贝到图层上, 所以图层上的动画对象和创建出来的动画对象是不同的.
控制开始时间 可以通过如下方式控制动画开始时间:
1 2 3 4 let anim = constructAnimation(size: view.bounds.size) anim.beginTime = CACurrentMediaTime () + 2.0 _contentView.layer.add(anim, forKey: "用于识别动画, 并进行后续操作" )
使用 fillMode 通过 fillMode
可以控制动画的开始和结束时候的显示效果, fillMode
的默认值是 removed
. 有如下 fillMode
可用:
removed
: 当动画结束后, 将动画改变的视图效果移除, 即”动画结束后恢复原状”.
backwards
: 在动画开始时显示动画的第一帧
forwards
: 在动画结束时保持显示动画最后一帧
both
: 组合 backwards
和 forwards
.
图层动画的实质 图层动画时, 并没有把实际的视图进行改变, 进行动画的只是一个 presentation layer
, 当动画结束后, 这个 layer 就被移除掉了, 然后原始的图层会显示出来, 所以才会看到动画结束之后又回到了原始状态的效果.
比如一个视图本来在屏幕的左边不可见位置, 要动画移动到屏幕中央, 此时需要设置动画对象的 isRemovedOnCompletion
为 false.
不过这个设置只是一个 “障眼法”, 因为视图的位置仍然是没有变化的, 看到的仅仅是动画结束时候最后一帧的”显示效果”, 如果视图有相关交互, 此时点击动画结束时候看到的那个”视图”, 实际上点不到任何东西.
正是这个原因, 所以在日常动画实现中, 如果不是特殊需要, 就不要去碰 fillMode. 原则是: 动画结束后就移除, 尽量不要使用其他 fillMode.
结束时, 如果想要视图处于结束位置, 则将视图或视图图层的位置按照动画的位置来改变即可.
总结: 如果视图原本在某个位置, 设置把视图从某个其他位置动画到原位置, 则可以不需要进行任何的后续操作. 但如果是视图原来不在最终位置, 此时就需要把视图的属性调整到和动画结束时候一致.
如果视图状态原本就是最终状态, 添加图层动画需要它从开始到结束, 则可以让 fillMode 设置为 backward.
弹性动画 弹性动画的典型实现如下所示, CASpringAnimation
是 CABasicAnimation
的子类:
1 2 3 4 5 6 7 8 let pulse = CASpringAnimation (keyPath: "transform.scale" ) pulse.damping = 7.5 pulse.fromValue = 1.25 pulse.toValue = 1.0 pulse.duration = pulse.settlingDuration layer? .add(pulse, forKey: nil )
弹性动画还可以设置如下属性:
damping
mass
stiffness
initialVelocity
如下代码是设置某个 TextField 当输入结束且文本长度超过5个的时候就添加动画:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 func textFieldDidEndEditing (_ textField : UITextField ) { guard let text = textField.text else { return } if text.count > 5 { addAnimationTo(textField: textField) } }private func addAnimationTo (textField : UITextField ) { let jump = CASpringAnimation (keyPath: "position.y" ) jump.fromValue = textField.layer.position.y + 1.0 jump.toValue = textField.layer.position.y jump.duration = jump.settlingDuration jump.initialVelocity = 100.0 jump.mass = 10.0 jump.damping = 50.0 jump.stiffness = 1500.0 textField.layer.add(jump, forKey: nil ) }
设置图层的其他属性进行弹性动画也是可以的, 比如下面的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 textField.layer.borderWidth = 3.0 textField.layer.borderColor = UIColor .clear.cgColor textField.layer.cornerRadius = 5 let flash = CASpringAnimation (keyPath: "borderColor" ) flash.damping = 7.0 flash.stiffness = 1500.0 flash.fromValue = UIColor (red: 1.0 , green: 0.27 , blue: 0.0 , alpha: 1.0 ).cgColor flash.toValue = UIColor .white.cgColor flash.duration = flash.settlingDuration textField.layer.add(flash, forKey: nil )
这样就实现了边框的弹性变化效果.
动画组和 KeyFrame 图层动画 动画组的作用是允许将多个动画合并然后针对一个 layer 进行添加动画.
KeyFrame 动画则是针对单个图层的单个属性指定关键帧的值和差值点数组, 这样可以把动画的关键点指定好, 从而实现单个复杂的动画.
通过如下的方式就可以创建关键帧动画, 同样, 关键帧动画也可以被添加到动画组中实现复杂动画.
1 2 3 4 5 6 let wobble = CAKeyframeAnimation (keyPath: "transform.rotation" ) wobble.duration = 0.25 wobble.repeatCount = 4 wobble.values = [0.0 , - .pi/4.0, 0.0, .pi/ 4.0 , 0.0 ] wobble.keyTimes = [0.0 , 0.25 , 0.5 , 0.75 , 1.0 ] heading.layer.add(wobble, forKey: nil )
在指定值的时候一定要注意, 在 OC 中不支持 Swift 的 Struct, 故如果是一系列的点数据, 比如 CGPoint, 则需要把它们包裹到 NSValue 中使用.
如下代码创建一个气球, 气球在动画末端的位置已经指定好了, 开始的时候气球在屏幕外部:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 let balloon = CALayer () balloon.contents = UIImage (named: "balloon" )? .cgImage balloon.frame = CGRect (x: - 50 , y: 0 , width: 50 , height: 65 ) view.layer.insertSublayer(balloon, below: username.layer)let flight = CAKeyframeAnimation (keyPath: "position" ) flight.duration = 12.0 flight.values = [CGPoint (x: - 50.0 , y: 0.0 ), CGPoint (x: view.frame.width + 50.0 , y: 160.0 ), CGPoint (x: - 50.0 , y: loginButton.center.y)] .map { NSValue (cgPoint: $0 ) } flight.keyTimes = [0.0 , 0.5 , 1.0 ] balloon.add(flight, forKey: nil ) balloon.position = CGPoint (x: - 50.0 , y: loginButton.center.y)
Shape 和 Mask 主要是实现多图层共存效果, 并对动画进行合成处理.
图形(Shape)主要通过 CAShapeLayer
来处理, 在图形绘制过程中, 并非对图层直接使用绘制命令, 而是交给图层一个 CGPath 来绘制. 可以通过 CG 的 API 或者 UIBezierPath
绘制. 把需要的 shape 创建出来之后, 就可以在它上面动画改变诸如如下的属性:
path:
fillColor:
lineDashPhase:
lineWidth:
关于图层创建和添加时机的问题, 在例子中也是使用 layoutSubviews
里面处理的, 如果怕重复, 添加标志位即可. 而 layer 间的关系在 didMoveToWindow
方法中配置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 override func didMoveToWindow () { layer.addSublayer(photoLayer) photoLayer.mask = maskLayer layer.addSublayer(circleLayer) }override func layoutSubviews () { super .layoutSubviews() guard let image = image else { return } photoLayer.frame = CGRect ( x: (bounds.size.width - image.size.width + lineWidth)/ 2 , y: (bounds.size.height - image.size.height - lineWidth)/ 2 , width: image.size.width, height: image.size.height ) circleLayer.path = UIBezierPath (ovalIn: bounds).cgPath circleLayer.strokeColor = UIColor .white.cgColor circleLayer.lineWidth = lineWidth circleLayer.fillColor = UIColor .clear.cgColor maskLayer.path = circleLayer.path maskLayer.position = CGPoint (x: 0.0 , y: 10.0 ) }
在创建动画的时候, 针对可动画属性进行相应变化即可. 比如将图层的 path
进行改变, 同时改变 mask
图层的 path
, 使得可以实现一些复杂的动画效果.
路径和绘制动画 这一节和 “Shape 和 Mask” 一节联系比较紧密.
例子中首先在刷新视图上的图层中绘制一个路径:
1 2 3 4 5 6 7 8 9 10 11 12 ovalShapeLayer.strokeColor = UIColor .white.cgColor ovalShapeLayer.fillColor = UIColor .clear.cgColor ovalShapeLayer.lineWidth = 4.0 ovalShapeLayer.lineDashPattern = [2 , 3 ]let refreshRadius = frame.size.height / 2 * 0.8 ovalShapeLayer.path = UIBezierPath (ovalIn: CGRect ( x: frame.size.width/ 2 - refreshRadius, y: frame.size.height/ 2 - refreshRadius, width: 2 * refreshRadius, height: 2 * refreshRadius)).cgPath layer.addSublayer(ovalShapeLayer)
当进度改变时, 调用如下代码改变 strokeEnd 的值:
1 ovalShapeLayer.strokeEnd = progress
在刷新开始的时候添加动画:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 let strokeStartAnimation = CABasicAnimation (keyPath: "strokeStart" ) strokeStartAnimation.fromValue = - 0.5 strokeStartAnimation.toValue = 1.0 let strokeEndAnimation = CABasicAnimation (keyPath: "strokeEnd" ) strokeEndAnimation.fromValue = 0.0 strokeEndAnimation.toValue = 1.0 let strokeAnimationGroup = CAAnimationGroup () strokeAnimationGroup.duration = 1.5 strokeAnimationGroup.repeatDuration = 5.0 strokeAnimationGroup.animations = [strokeStartAnimation, strokeEndAnimation] ovalShapeLayer.add(strokeAnimationGroup, forKey: nil )
通过上述代码, 即可实现该图层路径的 start 和 end 相互 catch 的效果. 因为 start 是从 -0.5 开始的, 故一直只是部分路径被绘制, 且由于 start 的范围在相同时间内更大, 故它的变化速率更快, 从而追赶 end 的速度会更快, 而 end 只需要 0 到 1 即可.
Replicating 动画 CAReplicatorLayer
用于复制一个图层上的动画, 它使用非常简单: 在目标图层上创建一些内容, 比如绘制的图形/图片或其他想绘制的内容, 而后 CAReplicatorLayer
就可以将它进行复制. 并且复制的孩子还可以根据需要来进行小修改. 而且它还有一个更强大的功能是允许设置每个复制品的动画延时.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 let replicator = CAReplicatorLayer ()let dot = CALayer ()let dotLength: CGFloat = 6.0 let dotOffset: CGFloat = 8.0 override func viewDidLoad () { super .viewDidLoad() replicator.frame = view.bounds view.layer.addSublayer(replicator) dot.frame = CGRect ( x: replicator.frame.size.width - dotLength, y: replicator.position.y, width: dotLength, height: dotLength ) dot.backgroundColor = UIColor .lightGray.cgColor dot.borderColor = UIColor (white: 1.0 , alpha: 1.0 ).cgColor dot.borderWidth = 0.5 dot.cornerRadius = 1.5 replicator.addSublayer(dot) }
CAReplicatorLayer 中主要使用如下属性:
通过设置这些属性, 并在原始图层(这里是 dot)上设置动画, 即可实现所有的东西一起动画的效果:
1 2 3 4 5 6 7 8 9 10 11 12 replicator.instanceCount = Int (view.frame.size.width / dotOffset) replicator.instanceTransform = CATransform3DMakeTranslation (- dotOffset, 0 , 0 ) replicator.instanceDelay = 0.02 let move = CABasicAnimation (keyPath: "position.y" ) move.fromValue = dot.position.y move.toValue = dot.position.y - 50.0 move.duration = 1.0 move.repeatCount = 10 dot.add(move, forKey: nil )
上面的测试动画中只是把位置移动, 下面来做一个更漂亮的动画:
1 2 3 4 5 6 7 8 let scale = CABasicAnimation (keyPath: "transform" ) scale.fromValue = NSValue (caTransform3D: CATransform3DIdentity ) scale.toValue = NSValue (caTransform3D: CATransform3DMakeScale (1.4 , 15 , 1.0 )) scale.duration = 0.33 scale.repeatCount = .infinity scale.autoreverses = true scale.timingFunction = CAMediaTimingFunction (name: .easeOut) dot.add(scale, forKey: "dotScale" )
关键就是上面的 replicator.instanceDelay = 0.02
这句话, 它让多个复制品间的动画有了一个间隔, 这样可以实现非常复杂的动画效果.
后续的动画也是添加到原始图层上就可以了.
另外 CAReplicatorLayer
图层的一些属性也可以进行动画, 比如上述的那几个属性, 通过对这几个属性进行动画, 可以实现非常炫酷的效果.
示例 需要实现如下图的效果:
并且具体要求如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
这个动画的实现主要思路就是: 使用一个 CAShapeLayer
图层实现中间的对勾动画, 另外一个 CAReplicatorLayer
图层实现周边射线的其他动画. 将两个图层添加到视图上即可. 只是注意一些细节参数.
展开来说就是: 旋转动画添加到 CAReplicatorLayer
上, 射线动画放到原始的 CAShapeLayer
图层上, 通过 CAReplicatorLayer
将原始图层复制, 即可实现效果, 对勾的动画独立在一个中间的图层上.