因为公司开发SDK的原因,公司开发需要做各种动画UI特效,也算是对动画有一定的了解,所以准备写个博客巩固下。本篇就对贝塞尔曲线加上属性动画来说一下。
1、线性别塞尔曲线的知识说明
线性贝塞尔曲线的公式如下:
B(t) = P0 + (P1 - P0)t 其中t的范围是[0,1](这个范围很是关键).
说白了就是两点Point0,Point1之间构成的一条直线(线段),其作用可以看做是从P0点到P1点的位移路径,假设A从P0走到P1,那么就是随着t的变换,A逐渐走P1点的一个过程。如下图(盗图):
我们知道一个点在平面中是有X,Y两个坐标点组成(特么的废话),假设Point0的坐标是(X0,Y0),Point1的坐标位(X1,Y1)那么A移动的过程中也即是随着t的渐变,A的横坐标点从X0逐渐移动到X1,纵坐标Y0逐渐移动到Y1的过程,用点来表示的话就是A经过一些列的点:(X0,Y0)–>(Xa,Ya)–>(Xb,Yb)–>…–>(X1,Y1)或者Point0–>PointA–>PointB–>…–>Point1才到Point1(此时t=1)。
我们在初中的时候学过直线方程y = kx +b是x跟y的关系,而贝塞尔曲线在应用中其实是x与t构成的直线函数以及y与t构成的直线函数关系:
B(tx) = (X1-X0)t+X0
B(ty) = (Y1-Y0)t + Y0
所以如果在android中想要让一个View从一位置移动到另外一个位置,如果用线性贝塞尔曲线的话,就是根据上面的两个函数根据变量t不断修改View的x和y的位置即可;当然因为(x,y)构成一个点,所以就是让View随着t的改变,从一个点移动到新的点的过程直到Point1。那么核心算法就是根据当前t的值(t->[0,1])根据上面的两个函数获取当前的newX和newY构成的坐标点(newX,newY)更新view的位置坐标点。
(感觉上面有点啰里啰嗦,表达能力欠缺)。
那么基本算法伪代码可以如下:
Point point0 = new Point(x0,y0);
Point point1 = new Point(x1,y1);
int k0 = x1- x0;//x与t直线函数的斜率
int k1 = y1 - y0;//y与t直线函数的斜率
float t = 0f;
while(t<=0){
//最新的x和y的位置
int newX = k0*t + x0;
int newY = k1*t + y0;
//更新view的位置方式1
LayoutParams params = view.getLayoutParams();
params.leftMargin = newX;
params.rightMargin = newY;
view.setLayoutParams(params);
//t以某种规则递增,比如每次增加0.1
t+=0.1
}
2、自己写一个小小的测试例子
根据是上面的说明以及伪代码例子程序如下:
代码也很简单,首先第一一个Point类,包换了x和y:
class Point {
protected float x;//横坐标
protected float y;//纵坐标
public Point(float x,float y){
this.x = x;
this.y = y;
}
}
然后定义一个线性贝塞尔曲线计算器类,这个类需要先传入线段的起始点,然后根据t来计算对应的新的Point对象:
class BezierLine {
private Point startPoint;//贝塞尔曲线起点
private Point endPoint;//贝塞尔曲线终点
public BezierLine(Point startPoint, Point endPoint) {
this.startPoint = startPoint;
this.endPoint = endPoint;
}
/**
* 根据线性贝塞尔函数,获取线性贝塞尔曲线上的某个点
* @param t 在[0,1]范围的某一个值
* @return 根据t的不同而返回的贝塞尔曲线的点
*/
public Point createBezierLine(float t){
float newX = (endPoint.x -startPoint.x)*t + startPoint.x;
float newY = (endPoint.y -startPoint.y)*t + startPoint.y;
return new Point(newX,newY);
}
}
以上可以说完事具备,只欠东风,那么怎么使用上述贝塞尔曲线来更新呢?这里提供一个简单的思路,就是用Handler来发送不断发送消息,简单的代码如下:
private float t = 0.0f;
private BezierLine bezierLine;
private Handler handler = new Handler(){
@Override
public void handleMessage(Message msg) {
t+=0.01f;
if(t>1.0){
return;
}
//获取当前t对应的Point位置
Point newPoint = bezierLine.createBezierLine(t);
//更新View的位置
updateViewLocation(newPoint);
//继续发送消息
handler.sendEmptyMessage(0);
}
};
private void updateViewLocation(Point point){
moveParams.leftMargin = (int)point.x;
moveParams.topMargin =(int)point.y;
moveView.setLayoutParams(moveParams);
}
运行的效果如图所示:
当然这种简单的运动通过scrollTo也可以简单实现,关于滚动的详细说明,可参考《View的滚动原理简单解析》和《View的滚动原理简单解析2》。
到此位置demo结束,只是简单的实现了两个点之间的运动估计,如果点很多的情况下怎么处理呢?比如如果View要进行如下的运动轨迹该怎么办?
在回答这个问题之前需要思考或者准备如下问题(以路径B为例):
1)从P0到P4的所需时间是多少毫秒?(答案是未知,也就是用户可配)
2)从P0–>P1、P1–>P2、P2–>P3、P3–>P4四个线段之间移动所消耗的时间是一致的吗?(答案是不一定,他们的耗时又长又短,这其实是一个动画中的插值器的概念,比如让P0–>P1的时间最短,其余的线段之间速度也设置的不一样)当然本文为了方便说明在此定义为点从P0–>P1、P1–>P2、P2–>P3、P3–>P4四个线段之间所耗时是一样的。也就是说假设传入的运动时间为duration表示,点的总数用n表示,那么每个线段之间的耗时比例关系如下:
根据上图很容易就得出了这些点与t得关系伪代码:
//点总数
int n;
//P0的起始时间为0
(0f,P0);
//p0 ..pn各个点与t的对应点断数
int lineSegment = n-1;
for (int i = 1; i < n; ++i) {
((float)i/lineSegment, Pi);
}
代码实现如下:
1)定义一个TPoint类来表示t和P0,P1,P2,P3,P4的关系
public class TPoint {
protected float t;
protected Point point;
public TPoint(float t,Point point){
this.t = t;
this.point = point;
}
}
2)初始化点数P0,P1,P2,P3,P4列表,并且绑定各个点对应的t
public void bindTPoint(){
//pointList是一个ArrayList
size = pointList.size();
tPoints = new TPoint[size];
tPoints[0] = new TPoint(0f,pointList.get(0));
//p0--p1构成的线段
int lineSegment = size -1;
for(int i=1;i<size;i++){
tPoints[i] = new TPoint((float)i/lineSegment,pointList.get(i));
}
}
就这样完成了第一步的工作!
在第一个例子的时候 BezierLine方式提供了startPoint和endPoint两个起止点就可以了,但是现在有若干个点怎么办呢,所以在这里优先重构的的就是BezierLine这个类:
/**
* 根据线性贝塞尔函数,获取线性贝塞尔曲线上的某个点
* @param t 在[0,1]范围的某一个值
* @param startPoint 贝塞尔曲线开始的点
* @param endPoint 贝塞尔曲线结束的点
* @return 根据t的不同而返回的贝塞尔曲线的点
*/
public static Point createBezierLine(float t,Point startPoint,Point endPoint){
float newX = (endPoint.x -startPoint.x)*t + startPoint.x;
float newY = (endPoint.y -startPoint.y)*t + startPoint.y;
return new Point(newX,newY);
}
注意此时相邻两个点组成的路径的范围t仍然为[0,1];只不过t要换一种方法来解释,t对于相邻点之间有点类似于求进度的算法,一个作为起点一个作为终点,其数学公式如下:
那么根据上面的公式,根据当前时间获取最新位置点Point对象的代码如下:
public Point getNewPoint(){
//当前时间
long currentTime = System.currentTimeMillis();
//当前时间进度
float currentProgress = (float) (currentTime - startTime) / DURATION ;
if(currentProgress>1.0){
finish = true;
}
//判断当前时间进度是在哪一个线段上
TPoint prePoint = tPoints[0];
for(int i=1;i<size;i++){
TPoint nextPoint = tPoints[i];
//运动的点在prePoint和nextPoint之间
if(currentProgress<nextPoint.t){
//当前点在当前路径的进度
float progress = (currentProgress-prePoint.t)/(nextPoint.t-prePoint.t);
return BezierLine.createBezierLine(progress,
prePoint.point, nextPoint.point);
}
prePoint = nextPoint;
}//end for
//其实这一步感觉不应该返回
return tPoints[size-1].point;
}
那么有了这个getNewPoint方法,直接调用第一个Demo中的updateViewLocation方法即可,同样是用handler来发送消息,并跟新位置,详细见文章最后代码下载链接,运行效果如图:
其实上面分析了这么多有点啰里啰嗦了,总结下来基本的算法思路很简单(在各个线段耗时相等的情况下,假设view从P0出发):
1)分配View 从P0到达P1,P2,P3,P4到这几个点的时间节点
1)根据当前时间和开始时间以及步骤1计算此时view应该位于哪一条路径上
2)计算view在当前路径相对当前路径起始点的进度。比如view此时位于p(n-1)和pn这条路径上,那么此进度当前路径的贝塞尔曲线的t值