Android自定義控制元件進階06-Path之貝塞爾曲線

2020-08-11 22:15:36

一.Path常用方法表

爲了相容性(偷懶) 本表格中去除了在API21(即安卓版本5.0)以上才新增的方法。忍不住吐槽一下,爲啥看起來有些順手就能寫的過載方法要等到API21才新增上啊。寶寶此刻內心也是崩潰的。

作用 相關方法 備註
移動起點 moveTo 移動下一次操作的起點位置
設定終點 setLastPoint 重置當前path中最後一個點位置,如果在繪製之前呼叫,效果和moveTo相同
連線直線 lineTo 新增上一個點到當前點之間的直線到Path
閉合路徑 close 連線第一個點連線到最後一個點,形成一個閉合區域
新增內容 addRect, addRoundRect, addOval, addCircle, addPath, addArc, arcTo 新增(矩形, 圓角矩形, 橢圓, 圓, 路徑, 圓弧) 到當前Path (注意addArc和arcTo的區別)
是否爲空 isEmpty 判斷Path是否爲空
是否爲矩形 isRect 判斷path是否是一個矩形
替換路徑 set 用新的路徑替換到當前路徑所有內容
偏移路徑 offset 對當前路徑之前的操作進行偏移(不會影響之後的操作)
貝塞爾曲線 quadTo, cubicTo 分別爲二次和三次貝塞爾曲線的方法
rXxx方法 rMoveTo, rLineTo, rQuadTo, rCubicTo 不帶r的方法是基於原點的座標系(偏移量), rXxx方法是基於當前點座標系(偏移量)
填充模式 setFillType, getFillType, isInverseFillType, toggleInverseFillType 設定,獲取,判斷和切換填充模式
提示方法 incReserve 提示Path還有多少個點等待加入**(這個方法貌似會讓Path優化儲存結構)**
布爾操作(API19) op 對兩個Path進行布爾運算(即取交集、並集等操作)
計算邊界 computeBounds 計算Path的邊界
重置路徑 reset, rewind 清除Path中的內容 reset不保留內部數據結構,但會保留FillType. rewind會保留內部的數據結構,但不保留FillType
矩陣操作 transform 矩陣變換

二.Path詳解

上一次除了一些常用函數之外,講解的基本上都是直線,本次需要瞭解其中的曲線部分,說到曲線,就不得不提大名鼎鼎的貝塞爾曲線。它的發明者是下面 下麪這個人(法國數學家PierreBézier)。

img

貝塞爾曲線能幹什麼?

貝塞爾曲線的運用是十分廣泛的,可以說貝塞爾曲線奠定了計算機繪圖的基礎(因爲它可以將任何複雜的圖形用精確的數學語言進行描述),在你不經意間就已經使用過它了。

你會使用Photoshop的話,你可能會注意到裏面有一個鋼筆工具,這個鋼筆工具核心就是貝塞爾曲線。

你說你不會PS? 沒關係,你如果看過前面的文章或者用過2D繪圖,肯定繪製過圓,圓弧,圓角矩形等這些東西。這裏面的圓弧部分全部都是貝塞爾曲線的運用。

貝塞爾曲線作用十分廣泛,簡單舉幾個的栗子:

  • QQ小紅點拖拽效果
  • 一些炫酷的下拉重新整理控制元件
  • 閱讀軟體的翻書效果
  • 一些平滑的折線圖的製作
  • 很多炫酷的動畫效果

如何輕鬆入門貝塞爾曲線?

雖然貝塞爾曲線用途非常廣泛,然而目前貌似並沒有適合的中文教學,能夠搜尋出來Android關於貝塞爾曲線的中文文章基本可以分爲以下幾種:

  • 科普型(只是讓人瞭解貝塞爾,並沒有實質性的內容)
  • 裝逼型(擺出來一大堆公式,參照一堆英文原文)
  • 基礎型(僅僅是講解貝塞爾曲線的兩個函數用法)
  • 實戰型(根據範例講解其中貝塞爾曲線的運用)

以上幾種型別中比較有用的就是基礎型和實戰型,但兩者各有不足,本文會綜合兩者內容,從零開始學習貝塞爾曲線。

第一步.理解貝塞爾曲線的原理

此處理解貝塞爾曲線並非是學會公式的推導過程(推倒(ノ*・ω・)ノ),而是要瞭解貝塞爾曲線是如何生成的。
貝塞爾曲線是用一系列點來控制曲線狀態的,我將這些點簡單分爲兩類:

型別 作用
數據點 確定曲線的起始和結束位置
控制點 確定曲線的彎曲程度

此處暫時僅作瞭解概念,接下來就會講解其中詳細的含義。

一階曲線原理:

一階曲線是沒有控制點的,僅有兩個數據點(A 和 B),最終效果一個線段。

img

上圖表示的是一階曲線生成過程中的某一個階段,動態過程可以參照下圖(本文中貝塞爾曲線相關的動態演示圖片來自維基百科)。

img

PS:一階曲線其實就是前面講解過的lineTo。

二階曲線原理:

二階曲線由兩個數據點(A 和 C),一個控制點(B)來描述曲線狀態,大致如下:

img

上圖中紅色曲線部分就是傳說中的二階貝塞爾曲線,那麼這條紅色曲線是如何生成的呢?接下來我們就以其中的一個狀態分析一下:

img

連線AB BC,並在AB上取點D,BC上取點E,使其滿足條件:[外連圖片轉存失敗,源站可能有防盜鏈機制 機製,建議將圖片儲存下來直接上傳(img-hB2IlzWx-1597155272636)(http://chart.googleapis.com/chart?cht=tx&chl=%5Cfrac%7BAD%7D%7BAB%7D%20%3D%20%5Cfrac%7BBE%7D%7BBC%7D)]

img

連線DE,取點F,使得:[外連圖片轉存失敗,源站可能有防盜鏈機制 機製,建議將圖片儲存下來直接上傳(img-FZii3eZf-1597155272663)(http://chart.googleapis.com/chart?cht=tx&chl=%5Cfrac%7BAD%7D%7BAB%7D%20%3D%20%5Cfrac%7BBE%7D%7BBC%7D%20%3D%20%5Cfrac%7BDF%7D%7BDE%7D)]

這樣獲取到的點F就是貝塞爾曲線上的一個點,動態過程如下:

img

PS: 二階曲線對應的方法是quadTo

三階曲線原理:

三階曲線由兩個數據點(A 和 D),兩個控制點(B 和 C)來描述曲線狀態,如下:

img

三階曲線計算過程與二階類似,具體可以見下圖動態效果:

img

PS: 三階曲線對應的方法是cubicTo

貝塞爾曲線速查表

強烈推薦點選這裏練習貝塞爾曲線,可以加深對貝塞爾曲線的理解程度。

第二步.瞭解貝塞爾曲線相關函數使用方法

一階曲線:

一階曲線是一條線段,非常簡單,可以參見上一篇文章Path之基本操作,此處就不詳細講解了。

二階曲線:

通過上面對二階曲線的簡單瞭解,我們知道二階曲線是由兩個數據點,一個控制點構成,接下來我們就用一個範例來演示二階曲線是如何運用的。

首先,兩個數據點是控制貝塞爾曲線開始和結束的位置,比較容易理解,而控制點則是控制貝塞爾的彎曲狀態,相對來說比較難以理解,所以本範例重點在於理解貝塞爾曲線彎曲狀態與控制點的關係,廢話不多說,先上效果圖:

img

爲了更加容易看出控制點與曲線彎曲程度的關係,上圖中繪製出了輔助點和輔助線,從上面的動態圖可以看出,貝塞爾曲線在動態變化過程中有類似於橡皮筋一樣的彈性效果,因此在製作一些彈性效果的時候很常用。

主要程式碼如下:

public class Bezier extends View {

    private Paint mPaint;
    private int centerX, centerY;

    private PointF start, end, control;

    public Bessel1(Context context) {
        super(context);
        mPaint = new Paint();
        mPaint.setColor(Color.BLACK);
        mPaint.setStrokeWidth(8);
        mPaint.setStyle(Paint.Style.STROKE);
        mPaint.setTextSize(60);

        start = new PointF(0,0);
        end = new PointF(0,0);
        control = new PointF(0,0);
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        centerX = w/2;
        centerY = h/2;

        // 初始化數據點和控制點的位置
        start.x = centerX-200;
        start.y = centerY;
        end.x = centerX+200;
        end.y = centerY;
        control.x = centerX;
        control.y = centerY-100;
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        // 根據觸控位置更新控制點,並提示重繪
        control.x = event.getX();
        control.y = event.getY();
        invalidate();
        return true;
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        // 繪製數據點和控制點
        mPaint.setColor(Color.GRAY);
        mPaint.setStrokeWidth(20);
        canvas.drawPoint(start.x,start.y,mPaint);
        canvas.drawPoint(end.x,end.y,mPaint);
        canvas.drawPoint(control.x,control.y,mPaint);

        // 繪製輔助線
        mPaint.setStrokeWidth(4);
        canvas.drawLine(start.x,start.y,control.x,control.y,mPaint);
        canvas.drawLine(end.x,end.y,control.x,control.y,mPaint);

        // 繪製貝塞爾曲線
        mPaint.setColor(Color.RED);
        mPaint.setStrokeWidth(8);

        Path path = new Path();

        path.moveTo(start.x,start.y);
        path.quadTo(control.x,control.y,end.x,end.y);

        canvas.drawPath(path, mPaint);
    }
}

三階曲線:

三階曲線由兩個數據點和兩個控制點來控制曲線狀態。

img

程式碼:

public class Bezier2 extends View {

    private Paint mPaint;
    private int centerX, centerY;

    private PointF start, end, control1, control2;
    private boolean mode = true;

    public Bezier2(Context context) {
        this(context, null);

    }

    public Bezier2(Context context, AttributeSet attrs) {
        super(context, attrs);

        mPaint = new Paint();
        mPaint.setColor(Color.BLACK);
        mPaint.setStrokeWidth(8);
        mPaint.setStyle(Paint.Style.STROKE);
        mPaint.setTextSize(60);

        start = new PointF(0, 0);
        end = new PointF(0, 0);
        control1 = new PointF(0, 0);
        control2 = new PointF(0, 0);
    }

    public void setMode(boolean mode) {
        this.mode = mode;
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        centerX = w / 2;
        centerY = h / 2;

        // 初始化數據點和控制點的位置
        start.x = centerX - 200;
        start.y = centerY;
        end.x = centerX + 200;
        end.y = centerY;
        control1.x = centerX;
        control1.y = centerY - 100;
        control2.x = centerX;
        control2.y = centerY - 100;

    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        // 根據觸控位置更新控制點,並提示重繪
        if (mode) {
            control1.x = event.getX();
            control1.y = event.getY();
        } else {
            control2.x = event.getX();
            control2.y = event.getY();
        }
        invalidate();
        return true;
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        //drawCoordinateSystem(canvas);

        // 繪製數據點和控制點
        mPaint.setColor(Color.GRAY);
        mPaint.setStrokeWidth(20);
        canvas.drawPoint(start.x, start.y, mPaint);
        canvas.drawPoint(end.x, end.y, mPaint);
        canvas.drawPoint(control1.x, control1.y, mPaint);
        canvas.drawPoint(control2.x, control2.y, mPaint);

        // 繪製輔助線
        mPaint.setStrokeWidth(4);
        canvas.drawLine(start.x, start.y, control1.x, control1.y, mPaint);
        canvas.drawLine(control1.x, control1.y,control2.x, control2.y, mPaint);
        canvas.drawLine(control2.x, control2.y,end.x, end.y, mPaint);

        // 繪製貝塞爾曲線
        mPaint.setColor(Color.RED);
        mPaint.setStrokeWidth(8);

        Path path = new Path();

        path.moveTo(start.x, start.y);
        path.cubicTo(control1.x, control1.y, control2.x,control2.y, end.x, end.y);

        canvas.drawPath(path, mPaint);
    }
}

三階曲線相比於二階曲線可以製作更加複雜的形狀,但是對於高階的曲線,用低階的曲線組合也可達到相同的效果,就是傳說中的降階。因此我們對貝塞爾曲線的封裝方法一般最高只到三階曲線。

降階與升階

型別 釋義 變化
降階 在保持曲線形狀與方向不變的情況下,減少控制點數量,即降低曲線階數 方法變得簡單,數據點變多,控制點可能減少,靈活性變弱
升階 在保持曲線形狀與方向不變的情況下,增加控制點數量,即升高曲線階數 方法更加複雜,數據點不變,控制點增加,靈活性變強

第三步.貝塞爾曲線使用範例

在製作這個範例之前,首先要明確一個內容,就是在什麼情況下需要使用貝塞爾曲線?

需要繪製不規則圖形時? 當然不是!目前來說,我覺得使用貝塞爾曲線主要有以下幾個方面(僅個人拙見,可能存在錯誤,歡迎指正)

序號 內容 用例
1 事先不知道曲線狀態,需要實時計算時 天氣預報氣溫變化的平滑折線圖
2 顯示狀態會根據使用者操作改變時 QQ小紅點,模擬翻書效果
3 一些比較複雜的運動狀態(配合PathMeasure使用) 複雜運動狀態的動畫效果

至於只需要一個靜態的曲線圖形的情況,用圖片豈不是更好,大量的計算會很不劃算。

如果是顯示SVG向量圖的話,已經有相關的解析工具了(內部依舊運用的有貝塞爾曲線),不需要手動計算。

貝塞爾曲線的主要優點是可以實時控制曲線狀態,並可以通過改變控制點的狀態實時讓曲線進行平滑的狀態變化。

接下來我們就用一個簡單的範例讓一個圓漸變成爲心形:

效果圖:

img

思路分析:

我們最終的需要的效果是將一個圓轉變成一個心形,通過分析可知,圓可以由四段三階貝塞爾曲線組合而成,如下:

img

心形也可以由四段的三階的貝塞爾曲線組成,如下:

img

兩者的差別僅僅在於數據點和控制點位置不同,因此只需要調整數據點和控制點的位置,就能將圓形變爲心形。

核心難點:

1.如何得到數據點和控制點的位置?

關於使用繪製圓形的數據點與控制點早就已經有人詳細的計算好了,可以參考stackoverflow的一個回答How to create circle with Bézier curves?其中的數據只需要拿來用即可。

而對於心形的數據點和控制點,可以由圓形的部分數據點和控制點平移後得到,具體參數可以自己慢慢調整到一個滿意的效果。

2.如何達到漸變效果?

漸變其實就是每次對數據點和控制點稍微移動一點,然後重繪介面,在短時間多次的調整數據點與控制點,使其逐漸接近目標值,通過不斷的重繪介面達到一種漸變的效果。過程可以參照下圖動態效果:

img

程式碼:

public class Bezier3 extends View {
    private static final float C = 0.551915024494f;     // 一個常數,用來計算繪製圓形貝塞爾曲線控制點的位置

    private Paint mPaint;
    private int mCenterX, mCenterY;

    private PointF mCenter = new PointF(0,0);
    private float mCircleRadius = 200;                  // 圓的半徑
    private float mDifference = mCircleRadius*C;        // 圓形的控制點與數據點的差值

    private float[] mData = new float[8];               // 順時針記錄繪製圓形的四個數據點
    private float[] mCtrl = new float[16];              // 順時針記錄繪製圓形的八個控制點

    private float mDuration = 1000;                     // 變化總時長
    private float mCurrent = 0;                         // 當前已進行時長
    private float mCount = 100;                         // 將時長總共劃分多少份
    private float mPiece = mDuration/mCount;            // 每一份的時長


    public Bezier3(Context context) {
        this(context, null);

    }

    public Bezier3(Context context, AttributeSet attrs) {
        super(context, attrs);

        mPaint = new Paint();
        mPaint.setColor(Color.BLACK);
        mPaint.setStrokeWidth(8);
        mPaint.setStyle(Paint.Style.STROKE);
        mPaint.setTextSize(60);


        // 初始化數據點

        mData[0] = 0;
        mData[1] = mCircleRadius;

        mData[2] = mCircleRadius;
        mData[3] = 0;

        mData[4] = 0;
        mData[5] = -mCircleRadius;

        mData[6] = -mCircleRadius;
        mData[7] = 0;

        // 初始化控制點

        mCtrl[0]  = mData[0]+mDifference;
        mCtrl[1]  = mData[1];

        mCtrl[2]  = mData[2];
        mCtrl[3]  = mData[3]+mDifference;

        mCtrl[4]  = mData[2];
        mCtrl[5]  = mData[3]-mDifference;

        mCtrl[6]  = mData[4]+mDifference;
        mCtrl[7]  = mData[5];

        mCtrl[8]  = mData[4]-mDifference;
        mCtrl[9]  = mData[5];

        mCtrl[10] = mData[6];
        mCtrl[11] = mData[7]-mDifference;

        mCtrl[12] = mData[6];
        mCtrl[13] = mData[7]+mDifference;

        mCtrl[14] = mData[0]-mDifference;
        mCtrl[15] = mData[1];
    }


    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        mCenterX = w / 2;
        mCenterY = h / 2;
    }


    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
         drawCoordinateSystem(canvas);       // 繪製座標系

        canvas.translate(mCenterX, mCenterY); // 將座標系移動到畫布中央
        canvas.scale(1,-1);                 // 翻轉Y軸

        drawAuxiliaryLine(canvas);


        // 繪製貝塞爾曲線
        mPaint.setColor(Color.RED);
        mPaint.setStrokeWidth(8);

        Path path = new Path();
        path.moveTo(mData[0],mData[1]);

        path.cubicTo(mCtrl[0],  mCtrl[1],  mCtrl[2],  mCtrl[3],     mData[2], mData[3]);
        path.cubicTo(mCtrl[4],  mCtrl[5],  mCtrl[6],  mCtrl[7],     mData[4], mData[5]);
        path.cubicTo(mCtrl[8],  mCtrl[9],  mCtrl[10], mCtrl[11],    mData[6], mData[7]);
        path.cubicTo(mCtrl[12], mCtrl[13], mCtrl[14], mCtrl[15],    mData[0], mData[1]);

        canvas.drawPath(path, mPaint);

        mCurrent += mPiece;
        if (mCurrent < mDuration){

            mData[1] -= 120/mCount;
            mCtrl[7] += 80/mCount;
            mCtrl[9] += 80/mCount;

            mCtrl[4] -= 20/mCount;
            mCtrl[10] += 20/mCount;

            postInvalidateDelayed((long) mPiece);
        }
    }

    // 繪製輔助線
    private void drawAuxiliaryLine(Canvas canvas) {
        // 繪製數據點和控制點
        mPaint.setColor(Color.GRAY);
        mPaint.setStrokeWidth(20);

        for (int i=0; i<8; i+=2){
            canvas.drawPoint(mData[i],mData[i+1], mPaint);
        }

        for (int i=0; i<16; i+=2){
            canvas.drawPoint(mCtrl[i], mCtrl[i+1], mPaint);
        }


        // 繪製輔助線
        mPaint.setStrokeWidth(4);

        for (int i=2, j=2; i<8; i+=2, j+=4){
            canvas.drawLine(mData[i],mData[i+1],mCtrl[j],mCtrl[j+1],mPaint);
            canvas.drawLine(mData[i],mData[i+1],mCtrl[j+2],mCtrl[j+3],mPaint);
        }
        canvas.drawLine(mData[0],mData[1],mCtrl[0],mCtrl[1],mPaint);
        canvas.drawLine(mData[0],mData[1],mCtrl[14],mCtrl[15],mPaint);
    }

    // 繪製座標系
    private void drawCoordinateSystem(Canvas canvas) {
        canvas.save();                      // 繪製做座標系

        canvas.translate(mCenterX, mCenterY); // 將座標系移動到畫布中央
        canvas.scale(1,-1);                 // 翻轉Y軸

        Paint fuzhuPaint = new Paint();
        fuzhuPaint.setColor(Color.RED);
        fuzhuPaint.setStrokeWidth(5);
        fuzhuPaint.setStyle(Paint.Style.STROKE);

        canvas.drawLine(0, -2000, 0, 2000, fuzhuPaint);
        canvas.drawLine(-2000, 0, 2000, 0, fuzhuPaint);

        canvas.restore();
    }
}

三.總結

其實關於貝塞爾曲線最重要的是核心理解貝塞爾曲線的生成方式,只有理解了貝塞爾曲線的生成方式,才能 纔能更好的運用貝塞爾曲線。在上一篇末尾說本篇可能涉及一點圖形渲染問題,不幸的是,本篇沒有了,請期待下一篇(可能會在下一篇中出現o( ̄︶ ̄)o),下一篇依舊Path相關內容,教給大家一些更好玩的東西。

解鎖新的境界之【繪製一個彈性的圓】

img

(,• ₃ •,)

PS: 由於本人水平有限,某些地方可能存在誤解或不準 不準確,如果你對此有疑問可以提交Issues進行反饋。

續集:

粉絲技術交流扣裙
在这里插入图片描述