Android 绘制N阶Bezier曲线

前段时间公司项目中有用到Bezier曲线的知识,在熟悉Bezier曲线原理和实现方式后,我突发奇想在Android客户端实现Bezier曲线的构建动画,于是有了BezierMaker这个项目。在讲解代码前,我们先了解一下Bezier曲线的原理。
项目源码:https://github.com/venshine/BezierMaker

什么是Bezier曲线?

贝塞尔曲线于1962年,由法国工程师皮埃尔·贝塞尔(Pierre Bézier)所广泛发表,他运用贝塞尔曲线来为汽车的主体进行设计。贝塞尔曲线最初由Paul de Casteljau于1959年运用de Casteljau算法开发,以稳定数值的方法求出Bezier曲线。

Bezier曲线的原理

Bezier曲线是用一系列点来控制曲线状态的,我们将这些点简单分为两类:

  • 数据点:确定曲线的起始和结束位置
  • 控制点:确定曲线的弯曲程度

线性Bezier曲线

给定数据点P0、P1,线性Bezier曲线只是一条两点之间的直线。

线性Bezier曲线公式

一阶Bezier矩阵

线性Bezier曲线演示动画,*t*在[0,1]区间

二阶Bezier曲线

二阶Bezier曲线的路径由数据点 P 0、P 2和控制点P 1的函数Bt)追踪。

二阶Bezier曲线公式

二阶Bezier矩阵

二阶Bezier曲线的结构

二阶Bezier曲线演示动画,*t*在[0,1]区间

为构建二阶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的连续点Bt),描述一条二阶Bezier曲线。

三阶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方向的“长度有多长”。

三阶Bezier曲线公式

三阶Bezier矩阵

三阶Bezier曲线的结构

三阶贝塞尔曲线演示动画,*t*在[0,1]区间

高阶Bezier曲线

N 阶贝塞尔曲线可如下推断。给定点P 0、P 1、…、P n,其Bezier曲线即

N阶Bezier曲线公式

上面的公式可用如下递归表达,即N 阶贝塞尔曲线是双N-1 阶贝塞尔曲线之间的插值。

N阶Bezier曲线递归公式

N阶Bezier矩阵

M(k)]

m(i,j)

四次贝塞尔曲线演示动画,*t*在[0,1]区间

Android 绘制N阶Bezier曲线_第1张图片

注解

  • 开始于P 0并结丛于P n的曲线,即所谓的端点插值法属性。
  • 曲线是直线的充分必要条件是所有的控制点都位在曲线上。同样的,贝塞尔曲线是直线的充分必要条件是控制点共线。
  • 曲线的起始点(结丛点)相切于贝塞尔多边形的第一节(最后一节)。
  • 一条曲线可在任意点切割成两条或任意多条子曲线,每一条子曲线仍是贝塞尔曲线。

Bezier曲线的作用

Bezier曲线的作用十分广泛,在Android移动应用开发中有很多应用的场景。例如QQ小红点拖拽效果、阅读软件的翻书效果、平滑折线图的制作、很多炫酷的动画效果等等。

BezierView实战讲解

看到这里,大部分人应该都了解了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 points;定义一段折线的点集,ArrayList> morepoints;定义一条折线的点集,ArrayList>> allpoints = new 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曲线代码的核心部分,有疑问的朋友可以在下面留言。

你可能感兴趣的:(Android进阶)