天天看點

Android 繪制N階Bezier曲線

本文是Mr.Huang同學投稿的一篇文章,也是本公衆号接受的第一篇觀衆投稿。再此非常感謝Mr.Huang同學。也歡迎其他同學踴躍投稿!這篇文章可讀性高,非常值得一讀。因為,我們知道随着Android的發展,對android開發者的要求也越來越高,公司和使用者都希望開發出酷炫的界面和動畫。而要想實作很多酷炫的界面和動畫,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曲線

給定資料點P0、P1,線性Bezier曲線隻是一條兩點之間的直線。

Android 繪制N階Bezier曲線
Android 繪制N階Bezier曲線
Android 繪制N階Bezier曲線

二階Bezier曲線

二階Bezier曲線的路徑由資料點 P 0、P 2和控制點P 1的函數B(t)追蹤。

Android 繪制N階Bezier曲線
Android 繪制N階Bezier曲線
Android 繪制N階Bezier曲線
Android 繪制N階Bezier曲線

為建構二階Bezier曲線,可以中介點Q 0和Q 1作為由0至1的t: 由P 0至P 1的連續點Q0,描述一條線性Bezier曲線。 由P 1至P 2的連續點Q 1,描述一條線性Bezier曲線。 由Q 0至Q 1的連續點B(t),描述一條二階Bezier曲線。

三階Bezier曲線

P 0、P 1、P 2、P 3四個點在平面或在三維空間中定義了三階Bezier曲線。曲線起始于P0走向P 1,并從P 2的方向來到P 3。一般不會經過P 1或P 2;這兩個點隻是在那裡提供方向。P 0和P 1之間的間距,決定了曲線在轉而趨進P 2之前,走向P 1方向的“長度有多長”。

Android 繪制N階Bezier曲線
Android 繪制N階Bezier曲線
Android 繪制N階Bezier曲線
Android 繪制N階Bezier曲線

高階Bezier曲線

N 階貝塞爾曲線可如下推斷。給定點P 0、P 1、…、P n,其Bezier曲線即

Android 繪制N階Bezier曲線

上面的公式可用如下遞歸表達,即N 階貝塞爾曲線是雙N-1 階貝塞爾曲線之間的插值。

Android 繪制N階Bezier曲線
Android 繪制N階Bezier曲線

![M(k)]]

Android 繪制N階Bezier曲線
Android 繪制N階Bezier曲線
Android 繪制N階Bezier曲線

注解:

  • 開始于P 0并結叢于P n的曲線,即所謂的端點插值法屬性。
  • 曲線是直線的充分必要條件是所有的控制點都位在曲線上。同樣的,貝塞爾曲線是直線的充分必要條件是控制點共線。
  • 曲線的起始點(結叢點)相切于貝塞爾多邊形的第一節(最後一節)。
  • 一條曲線可在任意點切割成兩條或任意多條子曲線,每一條子曲線仍是貝塞爾曲線。

Bezier曲線的作用

Bezier曲線的作用十分廣泛,在Android移動應用開發中有很多應用的場景。例如QQ小紅點拖拽效果、閱讀軟體的翻書效果、平滑折線圖的制作、很多炫酷的動畫效果等等。

BezierView實戰講解

看到這裡,大部分人應該都了解了Bezier曲線的原理。上面說到了Bezier曲線能夠實作很多炫酷的效果,是不是有點躍躍欲試了? 先欣賞一下最終的實作效果: 

Android 繪制N階Bezier曲線

下面講解一下BezierView這個類的主要代碼,這個自定義類包含了實作Bezier曲線動畫的主要代碼。首先看一下init()初始化方法,代碼如下所示:

Android 繪制N階Bezier曲線
Android 繪制N階Bezier曲線

init()方法主要作用是初始化一些必要的資訊,包括繪制Bezier曲線的畫筆、資料點和控制點的坐标,繪制路徑以及狀态變量。可以看到,我們在程式中初始化了兩個資料點和一個控制點,代碼中我們把資料點也作為控制點處理。由上面講解的Bezier曲線的原理可以知道,一階(線性)Bezier曲線包含兩個控制點,二階Bezier曲線包含三個控制點,三階Bezier曲線包含四個控制點,可以依次類推N階Bezier曲線包含N+1個控制點。

private ArrayList<PointF> buildBezierPoints() {
        ArrayList<PointF> 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<ArrayList<ArrayList<PointF>>> buildTangentPoints() {
        ArrayList<PointF> points;   // 1條線點集
        ArrayList<ArrayList<PointF>> morepoints;    // 多條線點集
        ArrayList<ArrayList<ArrayList<PointF>>> 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<PointF> points;​

​定義一段折線的點集,​

​ArrayList<ArrayList<PointF>> morepoints;​

​定義一條折線的點集,​

​ArrayList<ArrayList<ArrayList<PointF>>> 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<PointF> 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<PointF> 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曲線的繪制效果,如下圖所示:

Android 繪制N階Bezier曲線

以上就是繪制Bezier曲線代碼的核心部分,有疑問的朋友可以在下面留言。

Android 繪制N階Bezier曲線

關于Java和Android大牛頻道

Java和Android大牛頻道是一個數萬人關注的探讨Java和Android開發的公衆号,分享最有價值的幹貨文章,讓你成為這方面的大牛!

歡迎關注我們,一起讨論技術,掃描和長按下方的二維碼可快速關注我們

或搜尋微信公衆号:JANiubility,關注我們。

繼續閱讀