引言
在上一篇博客中我们已经了解了Core Animation,以及UIKit中的缓冲函数,并且使用缓冲让动画看起来流畅和自然了许多。本篇博客我们将继续更加深入的探讨缓冲函数,并且尝试自己来定义缓冲函数。
自定义缓冲
CAMediaTimingFunction除了init(name: CAMediaTimingFunctionName)初始化方法以外,还提供了一个init(controlPoints c1x: Float, _ c1y: Float, _ c2x: Float, _ c2y: Float)初始化方法,这个方法看起来有一些奇怪,文件中也没有什么注释,接下来我们需要使用它来创建自定义的缓冲函数,但是在创建之前最好还是先来了解一些CAMediaTimingFunction是如何工作的。
CAMediaTimingFunction原理
CAMediaTimingFunction函数的主要原理在于它把输入的时间转换成起点和终点之间成比例的改变。
这句话该怎么理解呢,我们来使用一个简单的图标来说明一下。
坐标系的纵轴代表改变的量,比如说position.x,从原来的0到动画后的100。
而横轴代表时间,那么动画属性改变的量将会随着时间而增加。
如果是线性的缓冲,那么动画属性的改变会随着时间的增加线性增加,也就是下图这样:
这条线的斜率就代表了动画的速度,而斜率的改变就代表了加速度。
原则上来讲,我们可以把动画的缓冲曲线设置为任意的曲线,但是实际上CAMediaTimingFunction是使用了一个叫做三次贝塞尔曲线的函数,这样的话就把缓冲曲线规定到了一个指定缓冲函数的子集中。
三次贝塞尔曲线
那么什么是三次贝塞尔曲线?我们来简单的介绍一下它,一个三次贝塞尔曲线由四个点定义,第一个和最后一个点代表了曲线的起点和终点,而剩下的两个点是控制点,它们会控制曲线的形状,这两个点并不在曲线上,我们可以把它想象成吸引曲线的磁铁。
我们来看一下具体实例:
这个图还是比较清晰的,point1和point2为两个控制点,控制了曲线的形状。
那么这个三次贝塞尔曲线放到动画的缓冲函数中会是怎么样的一个效果呢?
我们就单单看曲线的斜率,可以看得出一开始动画非常快,随着时间推移动画开始逐渐减慢,到了1/2处达到最慢,然后又开始逐渐加快。
通常我们不会使用这种动画缓存曲线,因为它实在有点奇怪不太符合现实世界中物体运动的规律。
那CAMediaTimingFunction为我们提供的5个常用的动画缓冲对应的曲线是什么样子呢?
使用曲线展示常见缓冲动画
我们也可以将常用动画缓冲曲线绘制出来看一下,CAMediaTimingFunction提供了一个方法可以获取到曲线对应的点:
/* 'idx' is a value from 0 to 3 inclusive. */
open func getControlPoint(at idx: Int, values ptr: UnsafeMutablePointer<Float>)
然后我们采用UIBezierPath将它绘制出来。
由于曲线的起点始终是{0,0}而终点始终是{1,1},所以我们只需要获取另外两个控制点的坐标即可。
代码如下:
获取控制点:
// 获取control point
func getControlPoint(frome functionName:CAMediaTimingFunctionName) -> (point1:CGPoint,point2:CGPoint) {
let function = CAMediaTimingFunction(name: functionName)
var controlPoint1:[Float] = [0,0]
var controlPoint2:[Float] = [0,0]
function.getControlPoint(at: 1, values: &controlPoint1)
function.getControlPoint(at: 2, values: &controlPoint2)
let controlPoint1CGPoint = CGPoint(x: CGFloat(controlPoint1[0]), y: CGFloat(controlPoint1[1]))
let controlPoint2CGPoint = CGPoint(x: CGFloat(controlPoint2[0]), y: CGFloat(controlPoint2[1]))
return (controlPoint1CGPoint,controlPoint2CGPoint)
}
创建三次贝塞尔曲线:
// 获取三次塞尔曲线
func getBezier(conrolPoint1:CGPoint,conrolPoint2:CGPoint) -> UIBezierPath {
let path = UIBezierPath()
path.move(to: CGPoint(x: 0, y: 0))
path.addCurve(to: CGPoint(x: 1, y: 1), controlPoint1: conrolPoint1, controlPoint2: conrolPoint2)
return path
}
绘制对应曲线:
func drawAllFunction() {
drawFunction(frome: .linear)
drawFunction(frome: .easeIn, offsexY: 120)
drawFunction(frome: .easeOut, offsexY: 240)
drawFunction(frome: .easeInEaseOut, offsexY: 360)
drawFunction(frome: .default, offsexY: 480)
}
func drawFunction(frome functionName:CAMediaTimingFunctionName,offsexY:CGFloat = 0.0) {
let layerView = UIView()
layerView.frame = CGRect(x: 100, y: offsexY + 100, width: 100, height: 100)
layerView.backgroundColor = UIColor.white
layerView.layer.shadowOffset = CGSize(width: 0, height: 3)
layerView.layer.shadowOpacity = 0.7
layerView.layer.shadowRadius = 3.0
self.view.addSubview(layerView)
let controlPoints = getControlPoint(frome: functionName)
let path = getBezier(conrolPoint1: controlPoints.point1, conrolPoint2: controlPoints.point2)
path.apply(CGAffineTransform(scaleX: 100, y: 100))
let shaperLayer = CAShapeLayer()
shaperLayer.strokeColor = UIColor.red.cgColor
shaperLayer.fillColor = UIColor.clear.cgColor
shaperLayer.lineWidth = 4.0
shaperLayer.path = path.cgPath
layerView.layer.addSublayer(shaperLayer)
layerView.layer.isGeometryFlipped = true
}
注意我们的顺序是.linear、.easeIn、.easeOut、.easeInEaseOut、.default,效果如下:
这样就更清晰的可以看每一个缓冲动画代表的动画到底是什么样子了。
也可以发现easeInEaseOut和default两个动画曲线确实相差不大,都是先加速达到最大速度后开始减速,只是它们的加速度有些不同,就是说动画在改变时的速率不一样。
自定义简单的缓冲函数
那么现在既然知道了它的原理,我们是不是也可以自己来定义缓冲函数了?
本篇博客一开始我们就提到了CAMediaTimingFunction为我们提供的init(controlPoints c1x: Float, _ c1y: Float, _ c2x: Float, _ c2y: Float)方法。
我们就使用它来创建一个适合时钟指针的动画缓冲,代码如下:
func startClock() {
drawClock()
startTimer()
}
// 指针
let secondView = UIView()
// 定时器
var timer:Timer?
func drawClock() {
let clockView = UIView()
clockView.frame = CGRect(x: 100, y: 100, width: 200, height: 200)
clockView.backgroundColor = UIColor.white
clockView.layer.cornerRadius = 100
clockView.layer.shadowOffset = CGSize(width: 0, height: 3)
clockView.layer.shadowOpacity = 0.7
self.view.addSubview(clockView)
// 添加指针
secondView.frame = CGRect(x: 0, y: 0, width: 2, height: 100)
secondView.center = CGPoint(x: 100, y: 100)
secondView.backgroundColor = UIColor.red
secondView.layer.anchorPoint = CGPoint(x: 0.5, y: 1)
clockView.addSubview(secondView)
}
func startTimer() {
timer = Timer.scheduledTimer(timeInterval: 1.0, target: self, selector: #selector(timerAction), userInfo: nil, repeats: true)
}
@objc func timerAction() {
let calendar = Calendar.current
let date = Date()
let second = calendar.component(.second, from: date)
let secondAngle = CGFloat(Double.pi * 2 / 60) * CGFloat(second)
let animation = CABasicAnimation()
animation.keyPath = "transform"
animation.toValue = NSValue(caTransform3D: CATransform3DMakeRotation(secondAngle, 0, 0, 1))
animation.duration = 0.5
animation.delegate = self
let function = CAMediaTimingFunction(controlPoints: 1, 0, 0.75, 1)
animation.timingFunction = function
secondView.layer.add(animation, forKey: nil)
}
func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
if flag {
let value = anim.value(forKey: "toValue") as! NSValue
let transform = value.caTransform3DValue
secondView.layer.transform = transform
}
}
效果如下:
看起来这个自定义函数非常适合时钟这个场景。
更加复杂的缓冲动画
那我们如何实现更加复杂一些的缓冲动画呢?比如说一个小球掉落到地上,然后再反弹回来再掉落到地上如此反复最终停止,三次贝塞尔曲线明显是不能满足这个动画了。
但是我们可以使用关键帧动画,为了使用关键帧动画来实现这个小球下落反弹的动画,我们需要在缓冲曲线中对每个比较显著的点创建一个关键帧(也就是每次反弹的峰值),然后应用缓冲函数把每段曲线链接起来。
与此同时我们需要通过keyTimes来指定每个关键帧的时间点,由于每次反弹的时间都会减少,所以关键帧并不会均匀分布。
另外我们还需要注意设置timingFunctions的值。
具体代码如下:
let ballView = UIView()
func dropBall() {
ballView.frame = CGRect(x: 100, y: 32, width: 50, height: 50)
ballView.backgroundColor = UIColor.red
ballView.layer.cornerRadius = 25
self.view.addSubview(ballView)
}
func startDrap() {
let positionAnimation = CAKeyframeAnimation()
positionAnimation.delegate = self
positionAnimation.keyPath = "position"
positionAnimation.duration = 1.0
positionAnimation.values = [
NSValue(cgPoint: CGPoint(x: 100, y: 32)),
NSValue(cgPoint: CGPoint(x: 100, y: 268)),
NSValue(cgPoint: CGPoint(x: 100, y: 140)),
NSValue(cgPoint: CGPoint(x: 100, y: 268)),
NSValue(cgPoint: CGPoint(x: 100, y: 220)),
NSValue(cgPoint: CGPoint(x: 100, y: 268)),
NSValue(cgPoint: CGPoint(x: 100, y: 250)),
NSValue(cgPoint: CGPoint(x: 100, y: 268))
]
positionAnimation.timingFunctions = [
CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeIn),
CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeOut),
CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeIn),
CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeOut),
CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeIn),
CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeOut),
CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeIn),
]
positionAnimation.keyTimes = [0.0,0.3,0.5,0.7,0.8,0.9,0.95,1.0]
ballView.layer.add(positionAnimation, forKey: nil)
}
func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
if flag {
if let keyFrameAnimation = anim as? CAKeyframeAnimation {
let value = keyFrameAnimation.values?.last as! NSValue
ballView.layer.position = value.cgPointValue
}
}
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
startDrap()
}
效果如下:
额看起来有一点像,但是又有一点生硬,而且这些坐标点都是我们一个个尝试出来的这真的很麻烦。那么有没有一个办法比如我们写一个函数然后来自动计算这些关键帧的点呢?
使用函数创建缓冲动画
在上面的示例中,我们首先把动画分割成了比较大的几块,然后用Core Animation的缓冲函数来大概形成了我们想要的曲线。
但是如果我们把动画分割成更小的更多的部分,那么我们就可以直接用直线来连接这些部分。
为了实现这个效果,我们需要做下面两件事:
- 把动画分割成多个关键帧。(越多越逼真熬)
- 用一个数学函数来表示弹性动画,使得可以对帧做偏移。
Core Animation按照每秒60帧来渲染屏幕更新,所以如果我们每秒生成60个关键帧就可以保证动画非常流畅。
但是缓冲背后的数学并不简单,罗伯特·彭纳有一个网页关于缓冲函数(Robert Penner's Easing Functions),包含了大多数普遍的缓冲函数的多种编程语言的实现的链接,包括C。
那我们就来借助它的函数来实现一个小球掉落反弹的缓冲函数,代码如下:
func startDrap() {
let fromPoint = CGPoint(x: 150, y: 32)
let toPoint = CGPoint(x: 150, y: 268)
let duration = 1.0
let numFrames = Int(duration * 60)
var frames = [NSValue]()
for i in 0..<numFrames {
let time = 1.0 / Double(numFrames) * Double(i)
let x = fromPoint.x + CGFloat(bounceEaseOut(t: CGFloat(time))) * (toPoint.x - fromPoint.x)
let y = fromPoint.y + CGFloat(bounceEaseOut(t: CGFloat(time))) * (toPoint.y - fromPoint.y)
frames.append(NSValue(cgPoint: CGPoint(x: x, y: y)))
}
let positionAnimation = CAKeyframeAnimation()
positionAnimation.delegate = self
positionAnimation.keyPath = "position"
positionAnimation.duration = 1.0
positionAnimation.values = frames
ballView.layer.add(positionAnimation, forKey: nil)
}
缓冲函数如下:
func bounceEaseOut(t:CGFloat) -> CGFloat {
if t < 4/11.0 {
return (121 * t * t) / 16.0
} else if t < 8/11.0 {
return (363/40.0 * t * t) - (99/10.0 * t) + 17/5.0
} else if t < 9/10.0 {
return (4356/361.0 * t * t) - (35442/1805.0 * t) + 16061/1805.0
}
return (54/5.0 * t * t) - (513/25.0 * t) + 268/25.0
}
效果如下:
结语
在本篇博客中,我们了解了CAMediaTimingFunction的原理,并且使用它创建了自定义的动画缓冲函数,同时又实用CAKeyframeAnimation避开了CAMediaTimingFunction的限制来创建了自定义的缓冲函数。
下一篇博客,我们开始来研究一下基于定时器的动画。