使用CAKeyFrameAnimation仿真减速动画
前段时间看lottie,想做一个动画来锻炼自己对动画的熟悉度。于是就有这篇博文
demo取名billiards,台球的意思,因为台球被打出去后会四处碰壁改变方向,减速前行,这个demo就是模仿台球四处碰壁的减速运动
仿真减速动画.gif减速运动
-
减速曲线
首先是减速。iOS系统中easyout系列动画就是一种减速运动。虽说都是减速运动,但是根据不同的初始速度和减速加速度,最后的减速曲线应该不尽相同。既然减速曲线不尽相同,更不谈和系统即订的动画曲线相同。所以说虽然都是减速运动,但是系统的easyout系列动画应该是不能做到完全贴合实际的减速动画。(减速曲线是指距离和时间的函数)
根据物理知识,和恒定外力的情况下,有个初始速度的物体运动距离和时间的函数是:s = v0 * t + 1/2 * a * t^2 其中(a<0)
其中v0是初始速度,a是加速度,如果恒定外力是摩擦力,那么a就是一个负值
时间距离曲线.png-
减速曲线在iOS帧动画中的实现
我选择用CAKeyFrameAnimation来进行动画,关于CAKeyFrameAnimation的属性意义可以参考这篇博客的内容
可以看到CAKeyFrameAnimation里并没有时间和距离函数这样的东西让我们去实现减速曲线
这里有个容易迷糊的地方,就是帧动画里没有物体速度的概念,每帧内的物体都是根据起终点和时间来均匀的把物体从起点移动到终点。那怎么实现速度的变化呢?答案是“扭曲时间”
通过调整时间的进度来调整物体的位置,来模拟速度的变化。这里增加一个中间变量——progress,用数学的语言来说就是时间和progress本是直线的关系,progress和位置是二次函数的关系(s = v0 * p + 1/2 * a * p^2)。但是iOS帧动画中,每一帧内部progress和位置s只能是直线关系,那么可以通过调整t和progress的关系来达到速度变化的目的,我理解为“扭曲时间”。有时时间过的快,位置变化的快,看起来速度就快;有时时间过的慢,位置变化的慢,看起来速度就慢。
根据复合函数的知识,s = p,那么进度和时间的关系就是 p = v0 * t + 1/2 * a * t^2,这样就可以通过调整进度来达到每帧动画内的速度变化
CAKeyFrameAnimation中的timeFuncs就是用来存放时间和进度关系的地方
-
贝塞尔曲线
找到了实现速度的方法,但仔细看一下我们赖以实现每帧内速度变化的进度函数,却是一个贝塞尔曲线,并不是计算好的二次函数。
现在就需要学习一下贝塞尔函数的知识,看看怎么把二次函数转换成贝塞尔函数。贝塞尔知识
用贝塞尔拟合二次函数,起终点好求,起点就是刚开始速度最大、位移为0的时候,终点则是速度为0,位移最大的时候。难的是求控制点。
由贝塞尔的定义可知,控制点和起终点的连线是起终点的控制点。我们需要的应该是只有一个控制点的贝塞尔曲线,因为只有一个控制点的贝塞尔是个二次函数。只有一个控制点的贝塞尔,该控制点是二次函数上起点和终点的切线交点。这样就可以通过二次函数的导数公式,来求得起终点的切线方程,进而计算出交点,也就是该段贝塞尔的控制点。
二次函数转贝塞尔.png这样我们就得到了一段拟合这次减速运动二次函数的贝塞尔曲线,需要注意的是,还需要对得到的贝塞尔进行scale上的变换。因为我们需要的是时间和进度的贝塞尔,在 CAMediaTimingFunction中,时间的值域是[0, 1],progress的值域也是[0, 1],所以我们还需要对控制点进行scale变换,使得起终点分别是[0, 0],[1, 1]。
附上贝塞尔生成代码
public class func bezierPointsFromMotionParabola(v0: CGFloat, a: CGFloat) -> CGPoint? {
//距离和时间函数 v0*t - 1/2 * a * t^2 = s
//起始点 s = 0, t = 0
//终点
let tMax = v0 / a //速度降到0所需时间
let sMax = (v0 * v0) / (2 * a) //距离最大值
//任意时间点的切线斜率 s' = v = v0 - at
//起始点的切线方程
let tangentSlopeBegin: CGFloat = v0
let tangentIntersectionBegin: CGFloat = 0 //beginP.x * v0 + b = beginP.y
let beginTangentLine = Line(slope: tangentSlopeBegin, intersectionWithY: tangentIntersectionBegin)// Line是一个直线类,具体见demo代码
//终点的切线方程
//斜率 vEnd = v0 - a * tMax = 0
let tangentAEnd: CGFloat = 0
let tangentBEnd: CGFloat = 1
let tangentCEnd: CGFloat = -sMax
let endTangentLine = Line(a: tangentAEnd, b: tangentBEnd, c: tangentCEnd)
//切线的交点,根据贝塞尔定义,也就是贝塞尔的控制点
guard let controlPInST = beginTangentLine.intersection(line: endTangentLine) else {
return nil
}
let controlPInPT = CGPoint(x: controlPInST.x / tMax, y: controlPInST.y / sMax)
return controlPInPT
}
-
分段减速曲线
可惜我们并不能将根据初始速度、加速度确定的距离和时间的二次函数推导出贝塞尔函数,直接用到 CAKeyframeAnimation的 timingFunctions中。最终小球的运动路径有许多关键帧,每个关键帧就是一个拐点,是小球碰到壁的地方。每个关键帧之间都需要一个时间进度函数来确定这个关键帧内小球怎么运动。拟合贝塞尔没有变,一个起始速度,加速度,再多加一个末速度,还是可以根据上面的原理生成每帧之间的贝塞尔函数
分段二次函数转贝塞尔.png附上分段后的贝塞尔生成代码
public class func bezierPointsFromSegmentMotion(v0: CGFloat, a: CGFloat, vEnd: CGFloat) -> CGPoint? {
let durtime = (v0 - vEnd) / a //速度降到vEnd所需时间
let distance = v0 * durtime - 1/2 * a * pow(durtime, 2) //总共的距离
//任意时间点的切线斜率 s' = v = v0 - at
//起始点的切线方程
let tangentSlopeBegin: CGFloat = v0
let tangentIntersectionBegin: CGFloat = 0 //beginP.x * v0 + b = beginP.y
let beginTangentLine = Line(slope: tangentSlopeBegin, intersectionWithY: tangentIntersectionBegin)
//终点的切线方程
let tangentAEnd: CGFloat = vEnd
let tangentBEnd: CGFloat = -1
let tangentCEnd: CGFloat = distance - vEnd * durtime
let endTangentLine = Line(a: tangentAEnd, b: tangentBEnd, c: tangentCEnd)
//切线的交点,根据贝塞尔定义,也就是贝塞尔的控制点
guard let controlPInST = beginTangentLine.intersection(line: endTangentLine) else {
return nil
}
let controlPInPT = CGPoint(x: controlPInST.x / durtime, y: controlPInST.y / distance)
return controlPInST
}
只不过我们需要多求一个末速度。我们可以根据下面的碰撞转向计算出小球的运动轨迹,知道每个关键帧的小球的起始位置和结束位置,这样就能根据关键帧的【时间和距离二次函数】和【运动距离】来计算出关键帧持续时间,进而计算出末速度。关键帧的持续时间可以收集起来,做一下变换,然后给CAKeyframeAnimation的 keyTimes用,末速度可以传递给上面的贝塞尔拟合func,计算出此段关键帧内的时间函数。
不过根据关键帧的【时间和距离二次函数】和【运动距离】来计算出关键帧持续时间可以抽象成这样的数学问题:
s = v0 * t - 1/2 * a * t^2 其中a>0,s是因变量,t是自变量,已知v0、a,对于给定的s,计算t
已知s求t.png这可真不容易呀。主要难度是提炼出t是因变量,s是自变量的函数。
最后我采用的方案是二次函数的求根公式
v0 * t - 1/2 * a * t^2 - s = 0的解是 [v0 - sqrt(v0^2 - 2 * a * s)] / a
这样就计算出了给定初速度、给定加速度,驶过给定距离需要的时间
移动t轴使用求根公式.png碰撞转向
碰撞转向问题的数学模型是向量在平面的反射。首先需要确定反射平面,然后计算反射向量
-
确定反射平面
我采取的方案是这样的:根据向量的两个方向的正负性可以确定可疑的两个两边,比如一个x、y方向都是正的向量,出发点又在矩形内,那么这个向量方向的衍生线一定只可能交矩形于bottom或者right。
速度向量符号.png然后起始点和右下角的连线构成一条线,判断起始点和速度向量构成的线,在起始点和右下角构成的线的哪一边,就可以判断是交与bottom还是right。我是用斜率判断是倾向x还是倾向y,再结合上面的判断出的两条边,就可以知道交于哪个边
速度向量具体边.png-
反射向量
下面两张图说明了小球的碰撞边界,求得反射向量的原理
小球碰撞.png 反射向量.png由图可见,只需要将反射线上一点作为向量的起点,然后就算出向量的终点,然后根据反射线算出向量终点的对称点,对称点和向量起点的连线就是反射向量
对称点的计算,我先计算出垂直于反射线,且经过向量终点的直线,然后算出该垂直线和反射线的交点,对称点和向量终点的中间点是刚才计算出来的交点,就能就算出对称点。
计算对称点.png求反射向量的代码实现
public extension CGVector {
public func reflexVector(line: Line) -> CGVector {
if line.a == 0 {
assert(line.b != 0)
return CGVector(dx: dx, dy: -dy)
}
let beginP = CGPoint(x: -line.c / line.a, y: 0) //以line和x轴的交点当作向量的起点,求对称点
let vectorEndP = CGPoint(x: dx + beginP.x, y: dy + beginP.y)
let c = -line.b * vectorEndP.x + line.a * vectorEndP.y
let verticalLine = Line(a: line.b, b: -line.a, c: c)
//向量垂直于line的点
guard let intersectionP = line.intersection(line: verticalLine) else {
assertionFailure()
return CGVector(dx: 0, dy: 0)
}
let reflexP = CGPoint(x: 2 * intersectionP.x - vectorEndP.x, y: 2 * intersectionP.y - vectorEndP.y)
let reflexVector = CGVector(dx: reflexP.x - beginP.x, dy: reflexP.y - beginP.y)
return reflexVector
}
}
使用CAKeyframeAnimation整合信息实现动画
通过上面的计算我们可以得到小球路径、每段运动的时间、每段运动的运动曲线。用这些信息生成一个CAKeyframeAnimation
let durtimes: [CGFloat] // 每帧的时长
let timeFuncs: [CAMediaTimingFunction] // 每帧的运动曲线
let path: UIBezierPath // 小球路径
//CAKeyframeAnimation的keyTimes是每帧在整体中的进度[0, 0<x<1, 1],所以要对durtimes进行转换
//算出整体时间
let sumTime = durtimes.reduce(0) { (result, item) -> CGFloat in
return result + item
}
//算出每帧结束时间
let accumTimes = durtimes.reduce([CGFloat]()) { (result, item) -> [CGFloat] in
var resultV = result
if result.count > 0 {
let last = result[result.count - 1]
resultV.append(last + item)
} else {
resultV.append(item)
}
return resultV
}
//转换成进度
var keyTimes = accumTimes.map { (item) -> NSNumber in
return NSNumber(floatLiteral: Double(item/sumTime))
}
//keyTimes是每帧的开始时间,加上最后一个1,这里需要在每帧结束时间前插入0
keyTimes.insert(NSNumber(value: 0), at: 0)
let animate = CAKeyframeAnimation(keyPath: "position")
animate.path = path.cgPath
animate.keyTimes = keyTimes
animate.timingFunctions = timeFuncs
animate.duration = CFTimeInterval(sumTime)
网友评论