前段时间公司项目中有用到Bezier曲线的知识,在熟悉Bezier曲线原理和实现方式后,我突发奇想在Android客户端实现Bezier曲线的构建动画,于是有了BezierMaker这个项目。在讲解代码前,我们先了解一下Bezier曲线的原理。
项目源码:https://github.com/venshine/BezierMaker
贝塞尔曲线于1962年,由法国工程师皮埃尔·贝塞尔(Pierre Bézier)所广泛发表,他运用贝塞尔曲线来为汽车的主体进行设计。贝塞尔曲线最初由Paul de Casteljau于1959年运用de Casteljau算法开发,以稳定数值的方法求出Bezier曲线。
Bezier曲线是用一系列点来控制曲线状态的,我们将这些点简单分为两类:
给定数据点P0、P1,线性Bezier曲线只是一条两点之间的直线。
二阶Bezier曲线的路径由数据点 P 0、P 2和控制点P 1的函数B(t)追踪。
为构建二阶Bezier曲线,可以中介点Q 0和Q 1作为由0至1的t:
由P 0至P 1的连续点Q 0,描述一条线性Bezier曲线。
由P 1至P 2的连续点Q 1,描述一条线性Bezier曲线。
由Q 0至Q 1的连续点B(t),描述一条二阶Bezier曲线。
P 0、P 1、P 2、P 3四个点在平面或在三维空间中定义了三阶Bezier曲线。曲线起始于P 0走向P 1,并从P 2的方向来到P 3。一般不会经过P 1或P 2;这两个点只是在那里提供方向。P 0和P 1之间的间距,决定了曲线在转而趋进P 2之前,走向P 1方向的“长度有多长”。
N 阶贝塞尔曲线可如下推断。给定点P 0、P 1、…、P n,其Bezier曲线即
上面的公式可用如下递归表达,即N 阶贝塞尔曲线是双N-1 阶贝塞尔曲线之间的插值。
注解:
Bezier曲线的作用十分广泛,在Android移动应用开发中有很多应用的场景。例如QQ小红点拖拽效果、阅读软件的翻书效果、平滑折线图的制作、很多炫酷的动画效果等等。
看到这里,大部分人应该都了解了Bezier曲线的原理。上面说到了Bezier曲线能够实现很多炫酷的效果,是不是有点跃跃欲试了?
先欣赏一下最终的实现效果:
下面讲解一下BezierView这个类的主要代码,这个自定义类包含了实现Bezier曲线动画的主要代码。首先看一下init()初始化方法,代码如下所示:
private void init() {
// 初始坐标
mControlPoints = new ArrayList<>(MAX_COUNT + 1);
int w = getResources().getDisplayMetrics().widthPixels;
mControlPoints.add(new PointF(w / 5, w / 5));
mControlPoints.add(new PointF(w / 3, w / 2));
mControlPoints.add(new PointF(w / 3 * 2, w / 4));
// 贝塞尔曲线画笔
mBezierPaint = new Paint();
mBezierPaint.setColor(Color.RED);
mBezierPaint.setStrokeWidth(BEZIER_WIDTH);
mBezierPaint.setStyle(Paint.Style.STROKE);
mBezierPaint.setAntiAlias(true);
// 移动点画笔
mMovingPaint = new Paint();
mMovingPaint.setColor(Color.BLACK);
mMovingPaint.setAntiAlias(true);
mMovingPaint.setStyle(Paint.Style.FILL);
// 控制点画笔
mControlPaint = new Paint();
mControlPaint.setColor(Color.BLACK);
mControlPaint.setAntiAlias(true);
mControlPaint.setStyle(Paint.Style.STROKE);
// 切线画笔
mTangentPaint = new Paint();
mTangentPaint.setColor(Color.parseColor(TANGENT_COLORS[0]));
mTangentPaint.setAntiAlias(true);
mTangentPaint.setStrokeWidth(TANGENT_WIDTH);
mTangentPaint.setStyle(Paint.Style.FILL);
// 固定线画笔
mLinePaint = new Paint();
mLinePaint.setColor(Color.LTGRAY);
mLinePaint.setStrokeWidth(CONTROL_WIDTH);
mLinePaint.setAntiAlias(true);
mLinePaint.setStyle(Paint.Style.FILL);
// 点画笔
mTextPointPaint = new Paint();
mTextPointPaint.setColor(Color.BLACK);
mTextPointPaint.setAntiAlias(true);
mTextPointPaint.setTextSize(TEXT_SIZE);
// 文字画笔
mTextPaint = new Paint();
mTextPaint.setColor(Color.GRAY);
mTextPaint.setAntiAlias(true);
mTextPaint.setTextSize(TEXT_SIZE);
mBezierPath = new Path();
mState |= STATE_READY | STATE_TOUCH;
}
init()方法主要作用是初始化一些必要的信息,包括绘制Bezier曲线的画笔、数据点和控制点的坐标,绘制路径以及状态变量。可以看到,我们在程序中初始化了两个数据点和一个控制点,代码中我们把数据点也作为控制点处理。由上面讲解的Bezier曲线的原理可以知道,一阶(线性)Bezier曲线包含两个控制点,二阶Bezier曲线包含三个控制点,三阶Bezier曲线包含四个控制点,可以依次类推N阶Bezier曲线包含N+1个控制点。
private ArrayList buildBezierPoints() {
ArrayList points = new ArrayList<>();
int order = mControlPoints.size() - 1;
float delta = 1.0f / FRAME;
for (float t = 0; t <= 1; t += delta) {
// Bezier点集
points.add(new PointF(deCasteljauX(order, 0, t), deCasteljauY(order, 0, t)));
}
return points;
}
这段代码主要用于创建Bezier点集。熟悉Android Path用法的同学应该知道Path类中有两个方法quadTo和cubicTo,通过这两个方法可以画出二阶和三阶Bezier曲线。因为我们这里需要创建更高阶的Bezier曲线,因此我采用德卡斯特里奥算法(De Casteljau’s Algorithm)来实现Bezier曲线。
不熟悉德卡斯特里奥算法的同学可以参考我翻译的一篇文章《德卡斯特里奥算法——找到Bezier曲线上的一个点》。
在上面的方法中,我通过deCasteljauX和deCasteljauY方法创建某一时刻Bezier曲线上的点,再通过把参数t等分1000份,每个时刻的点连接在一起就能形成一条完整的Bezier曲线。有的同学看到这里可能会问,为什么要等分1000份啊?其实t等分多少取决于曲线的平滑程度。1000只是一个相对值,t等分越多,曲线越平滑,反之亦然。
private float deCasteljauX(int i, int j, float t) {
if (i == 1) {
return (1 - t) * mControlPoints.get(j).x + t * mControlPoints.get(j + 1).x;
}
return (1 - t) * deCasteljauX(i - 1, j, t) + t * deCasteljauX(i - 1, j + 1, t);
}
下面看一下德卡斯特里奥算法的实现程序,通过递归实现N阶Bezier曲线上的点。上面短短几行代码就能实现Bezier,是不是很简单。在这里我就不解释这段代码了,不懂的同学可以参考上一段解释,看我翻译的德卡斯特里奥算法。
private ArrayList>> buildTangentPoints() {
ArrayList points; // 1条线点集
ArrayList> morepoints; // 多条线点集
ArrayList>> allpoints = new ArrayList<>(); // 所有点集
PointF point;
int order = mControlPoints.size() - 1;
float delta = 1.0f / FRAME;
for (int i = 0; i < order - 1; i++) {
int size = allpoints.size();
morepoints = new ArrayList<>();
for (int j = 0; j < order - i; j++) {
points = new ArrayList<>();
for (float t = 0; t <= 1; t += delta) {
float p0x = 0;
float p1x = 0;
float p0y = 0;
float p1y = 0;
int z = (int) (t * FRAME);
if (size > 0) {
p0x = allpoints.get(i - 1).get(j).get(z).x;
p1x = allpoints.get(i - 1).get(j + 1).get(z).x;
p0y = allpoints.get(i - 1).get(j).get(z).y;
p1y = allpoints.get(i - 1).get(j + 1).get(z).y;
} else {
p0x = mControlPoints.get(j).x;
p1x = mControlPoints.get(j + 1).x;
p0y = mControlPoints.get(j).y;
p1y = mControlPoints.get(j + 1).y;
}
float x = (1 - t) * p0x + t * p1x;
float y = (1 - t) * p0y + t * p1y;
point = new PointF(x, y);
points.add(point);
}
morepoints.add(points);
}
allpoints.add(morepoints);
}
return allpoints;
}
这段代码不长,但是乍一看有三重for循环,很多人可能头大了。其实听我慢慢分析,你会感觉很简单。这段代码就是用来实现创建Bezier曲线过程中的折线点集。通过德卡斯特里奥算法我们知道,二阶Bezier曲线有三个控制点和一条折线,在t时刻通过三个点和一条折线确定曲线上唯一的点。接下来三阶Bezier曲线在t时刻通过四个点和二条折线确定曲线上唯一的点。以此类推,N阶Bezier曲线在t时刻通过N+1个点和N-1条折线确定曲线上唯一的点。理解上面的话,我们就能通过程序来实现折线点集。ArrayList
定义一段折线的点集,ArrayList
定义一条折线的点集,ArrayList
定义多条折线的点集。那么如何确定折线段上的点呢?我们可以通过下面的公式来获取:B(t) = (1-t) * P0 + t * P1;
,其中P0为一条折线段的起点坐标,P1为终点坐标。这个公式的证明参考德卡斯特里奥算法。看最里层for循环,获取t时刻每一段折线的点集。其中,如果还没有一条折线产生,则取控制点坐标实现最外层折线,当最外层折线形成后,里层的折线坐标依次取外层t时刻的折线上的点。中间层for循环实现每一条折线的点集,最外层for循环控制折线的层树。例如三阶Bezier曲线有两层折线,外层折线包含两条线段,里层折线包含一条线段。
@Override
protected void onDraw(Canvas canvas) {
if (isRunning() && !isTouchable()) {
if (mBezierPoint == null) {
mBezierPath.reset();
mBezierPoint = mBezierPoints.get(0);
mBezierPath.moveTo(mBezierPoint.x, mBezierPoint.y);
}
// 控制点和控制点连线
int size = mControlPoints.size();
PointF point;
for (int i = 0; i < size; i++) {
point = mControlPoints.get(i);
if (i > 0) {
// 控制点连线
canvas.drawLine(mControlPoints.get(i - 1).x, mControlPoints.get(i - 1).y, point.x, point.y,
mLinePaint);
}
// 控制点
canvas.drawCircle(point.x, point.y, CONTROL_RADIUS, mControlPaint);
// 控制点文本
canvas.drawText("p" + i, point.x + CONTROL_RADIUS * 2, point.y + CONTROL_RADIUS * 2, mTextPointPaint);
// 控制点文本展示
canvas.drawText("p" + i + " ( " + new DecimalFormat("##0.0").format(point.x) + " , " + new DecimalFormat
("##0.0").format(point.y) + ") ", REGION_WIDTH, mHeight - (size - i) * TEXT_HEIGHT, mTextPaint);
}
// 切线
if (mTangent && mInstantTangentPoints != null && !isStop()) {
int tsize = mInstantTangentPoints.size();
ArrayList tps;
for (int i = 0; i < tsize; i++) {
tps = mInstantTangentPoints.get(i);
int tlen = tps.size();
for (int j = 0; j < tlen - 1; j++) {
mTangentPaint.setColor(Color.parseColor(TANGENT_COLORS[i]));
canvas.drawLine(tps.get(j).x, tps.get(j).y, tps.get(j + 1).x, tps.get(j + 1).y,
mTangentPaint);
canvas.drawCircle(tps.get(j).x, tps.get(j).y, CONTROL_RADIUS, mTangentPaint);
canvas.drawCircle(tps.get(j + 1).x, tps.get(j + 1).y, CONTROL_RADIUS, mTangentPaint);
}
}
}
// Bezier曲线
mBezierPath.lineTo(mBezierPoint.x, mBezierPoint.y);
canvas.drawPath(mBezierPath, mBezierPaint);
// Bezier曲线起始移动点
canvas.drawCircle(mBezierPoint.x, mBezierPoint.y, CONTROL_RADIUS, mMovingPaint);
// 时间展示
canvas.drawText("t:" + (new DecimalFormat("##0.000").format((float) mR / FRAME)), mWidth - TEXT_HEIGHT *
3, mHeight - TEXT_HEIGHT, mTextPaint);
mHandler.removeMessages(HANDLER_WHAT);
mHandler.sendEmptyMessage(HANDLER_WHAT);
}
if (isTouchable()) {
// 控制点和控制点连线
int size = mControlPoints.size();
PointF point;
for (int i = 0; i < size; i++) {
point = mControlPoints.get(i);
if (i > 0) {
canvas.drawLine(mControlPoints.get(i - 1).x, mControlPoints.get(i - 1).y, point.x, point.y,
mLinePaint);
}
canvas.drawCircle(point.x, point.y, CONTROL_RADIUS, mControlPaint);
canvas.drawText("p" + i, point.x + CONTROL_RADIUS * 2, point.y + CONTROL_RADIUS * 2, mTextPointPaint);
canvas.drawText("p" + i + " ( " + new DecimalFormat("##0.0").format(point.x) + " , " + new DecimalFormat
("##0.0").format(point.y) + ") ", REGION_WIDTH, mHeight - (size - i) * TEXT_HEIGHT, mTextPaint);
}
}
}
下面我们来绘制Bezier曲线。这段代码很长,但是很简单。首先判断状态,只有运行且非触摸状态时,才可以绘制Bezier曲线。触摸状态时仅绘制控制点和控制点之间的连线。运行状态,如果当前Bezier曲线移动起始点为空,则重置Path,重新设置Path的起始点。接下来for循环绘制控制点和控制点连线以及文本。接下来我们解析瞬时折线点,通过双重for循环取出mInstantTangentPoints中的点,两两连接绘制折线。接下来通过Path连接Bezier曲线上某一时刻的点,绘制该点的路径。其实onDraw里的代码只绘制了某一时刻的点,我们在最后通过handler方式改变t时刻,并重复调用onDraw绘制,一直到曲线绘制周期结束。这样,一条完美的Bezier曲线就绘制好了。
private Handler mHandler = new Handler() {
@Override
public void handleMessage(Message msg) {
if (msg.what == HANDLER_WHAT) {
mR += mRate;
if (mR >= mBezierPoints.size()) {
removeMessages(HANDLER_WHAT);
mR = 0;
mState &= ~STATE_RUNNING;
mState &= ~STATE_STOP;
mState |= STATE_READY | STATE_TOUCH;
if (mLoop) {
start();
}
return;
}
if (mR != mBezierPoints.size() - 1 && mR + mRate >= mBezierPoints.size()) {
mR = mBezierPoints.size() - 1;
}
// Bezier点
mBezierPoint = new PointF(mBezierPoints.get(mR).x, mBezierPoints.get(mR).y);
// 切线点
if (mTangent) {
int size = mTangentPoints.size();
ArrayList instantpoints;
mInstantTangentPoints = new ArrayList<>();
for (int i = 0; i < size; i++) {
int len = mTangentPoints.get(i).size();
instantpoints = new ArrayList<>();
for (int j = 0; j < len; j++) {
float x = mTangentPoints.get(i).get(j).get(mR).x;
float y = mTangentPoints.get(i).get(j).get(mR).y;
instantpoints.add(new PointF(x, y));
}
mInstantTangentPoints.add(instantpoints);
}
}
if (mR == mBezierPoints.size() - 1) {
mState |= STATE_STOP;
}
invalidate();
}
}
};
再来看一下handler模块。这段代码通过一定速率控制并获取曲线上的点以及折线上的点,保证曲线最后一帧绘制完成后结束调用。我们再来欣赏一下Bezier曲线的绘制效果,如下图所示:
以上就是绘制Bezier曲线代码的核心部分,有疑问的朋友可以在下面留言。