1. 程式人生 > >自定義控制元件之二階貝塞爾曲線方法詳解

自定義控制元件之二階貝塞爾曲線方法詳解

前言:先膜拜一下啟艦大神,本想自己寫一篇關於貝塞爾曲線的文章,但無奈此大神寫的太6了 ,所以直接轉載

相關文章:
《Android自定義控制元件三部曲文章索引》http://blog.csdn.net/harvic880925/article/details/50995268

從這篇開始,我將延續androidGraphics系列文章把圖片相關的知識給大家講完,這一篇先稍微進階一下,給大家把《Android Graphics(二):路徑及文字》略去的quadTo(二階貝塞爾)函式,給大家補充一下。
本篇最終將以兩個例子給大家演示貝塞爾曲線的強大用途:
1、手勢軌跡

利用貝塞爾曲線,我們能實現平滑的手勢軌跡效果
2、水波紋效果



電池充電時,有些手機會顯示水波紋效果,就是這樣做出來的。
廢話不多說,開整吧

一、概述

《android Graphics(二):路徑及文字》中我們略去了有關所有貝賽爾曲線的知識,在Path中有四個函式與貝賽爾曲線有關:

[java] view plain copy print ? 在CODE上檢視程式碼片 派生到我的程式碼片
  1. //二階貝賽爾  
  2. public void quadTo(float x1, float y1, 
    float x2, float y2)  
  3. public void rQuadTo(float dx1, float dy1, float dx2, float dy2)  
  4. //三階貝賽爾  
  5. public void cubicTo(float x1, float y1, float x2, float y2,float
     x3, float y3)  
  6. public void rCubicTo(float x1, float y1, float x2, float y2,float x3, float y3)  
//二階貝賽爾
public void quadTo(float x1, float y1, float x2, float y2)
public void rQuadTo(float dx1, float dy1, float dx2, float dy2)
//三階貝賽爾
public void cubicTo(float x1, float y1, float x2, float y2,float x3, float y3)
public void rCubicTo(float x1, float y1, float x2, float y2,float x3, float y3)
這裡的四個函式的具體意義我們後面會具體詳細講解,我們這篇也就是利用這四個函式來實現我們的貝賽爾曲線相關的效果的。

1、貝賽爾曲線來源

在數學的數值分析領域中,貝賽爾曲線(Bézier曲線)是電腦圖形學中相當重要的引數曲線。更高維度的廣泛化貝塞爾曲線就稱作貝塞爾曲面,其中貝塞爾三角是一種特殊的例項。
貝塞爾曲線於1962年,由法國工程師皮埃爾·貝塞爾(Pierre Bézier)所廣泛發表,他運用貝塞爾曲線來為汽車的主體進行設計。貝塞爾曲線最初由Paul de Casteljau於1959年運用de Casteljau演算法開發,以穩定數值的方法求出貝塞爾曲線。

2、貝賽爾曲線公式

這部分是很有難度的,大家做好準備了哦

一階貝賽爾曲線

其公式可概括為:

對應動畫演示為:


P0為起點、P1為終點,t表示當前時間,B(t)表示公式的結果值。
注意,曲線的意義就是公式結果B(t)隨時間的變化,其取值所形成的軌跡。在動畫中,黑色點表示在當前時間t下公式B(t)的取值。而紅色的那條線就不在各個時間點下不同取值的B(t)所形成的軌跡。
總而言之:對於一階貝賽爾曲線,大家可以理解為在起始點和終點形成的這條直線上,勻速移動的點。

二階貝賽爾曲線

同樣,先來看看二階貝賽爾曲線的公式(雖然看不懂,呵呵)


大家也不用研究這個公式了,沒一定數學功底也研究不出來了啥,咱還是看動畫吧


在這裡P0是起始點,P2是終點,P1是控制點
假設將時間定在t=0.25的時刻,此時的狀態如下圖所示:


首先,P0點和P1點形成了一條貝賽爾曲線,還記得我們上面對一階貝賽爾曲線的總結麼:就是一個點在這條直線上做勻速運動;所以P0-P1這條直線上的移動的點就是Q0;
同樣,P1,P2形成了一條一階貝賽爾曲線,在這條一階貝賽爾曲線上,它們的隨時間移動的點是Q1
最後,動態點Q0和Q1又形成了一條一階貝賽爾曲線,在它們這條一階貝賽爾曲線動態移動的點是B
而B的移動軌跡就是這個二階貝賽爾曲線的最終形態。從上面的講解大家也可以知道,之所以叫它二階貝賽爾曲線是因為,B的移動軌跡是建立在兩個一階貝賽爾曲線的中間點Q0,Q1的基礎上的。
在理解了二階貝賽爾曲線的形成原理以後,我們就不難理解三階貝賽爾曲線了

三階貝賽爾曲線

同樣,先列下基本看不懂的公式


這玩意估計也看不懂,講了也沒什麼意義,還是結合動畫來吧


同樣,我們取其中一點來講解軌跡的形成原理,當t=0.25時,此時狀態如下:


同樣,P0是起始點,P3是終點;P1是第一個控制點,P2是第二個控制點;
首先,這裡有三條一階貝賽爾曲線,分別是P0-P1,P1-P2,P2-P3;
他們隨時間變化的點分別為Q0,Q1,Q2
然後是由Q0,Q1,Q2這三個點,再次連線,形成了兩條一階貝賽爾曲線,分別是Q0—Q1,Q1—Q2;他們隨時間變化的點為R0,R1
同樣,R0和R1同樣可以連線形成一條一階貝賽爾曲線,在R0—R1這條貝賽爾曲線上隨時間移動的點是B
而B的移動軌跡就是這個三階貝賽爾曲線的最終形狀。
從上面的解析大家可以看出,所謂幾階貝賽爾曲線,全部是由一條條一階貝賽爾曲線搭起來的;
在上圖中,形成一階貝賽爾曲線的直線是灰色的,形成二階貝賽爾曲線線是綠色的,形成三階貝賽爾曲線的線是藍色的。
在理解了上面的二階和三階貝賽爾曲線以後,我們再來看幾個貝賽爾曲線的動態圖

四階貝賽爾曲線


五階貝賽爾曲線


這裡就不再一一講解形成原理了,大家理解了二階和三階貝賽爾曲線以後,這兩條的看看就好了,想必大家也是能自己推出四階貝賽爾曲線的形成原理的。

3、貝賽爾曲線與PhotoShop鋼筆工具

如果有些同學不懂PhotoShop,這篇文章可能就會有些難度了,本篇文章主要是利用PhotoShop的鋼筆工具來得出具體貝塞爾影象的
這麼屌的貝賽爾曲線,在專業繪圖工具PhotoShop中當然會有它的蹤影,它就是鋼筆工具,鋼筆工具所使用的路徑彎曲效果就是二階貝賽爾曲線。
我來給大家演示一下鋼筆工具的用法:


我們拿最終成形的圖形來看一下為什麼鋼筆工具是二階貝賽爾曲線:


右圖演示的假設某一點t=0.25時,動態點B的位置圖
同樣,這裡P0是起始點,P2是終點,P1是控制點;
P0-P1、P1-P2形成了第一層的一階貝賽爾曲線。它們隨時間的動態點分別是Q0,Q1
動態點Q0,Q1又形成了第二層的一階貝賽爾曲線,它們的動態點是B.而B的軌跡跟鋼筆工具的形狀是完全一樣的。所以鋼筆工具的拉伸效果是使用的二階貝賽爾曲線!
這個圖與上面二階貝賽爾曲線t=0.25時的曲線差不多,大家理解起來難度也不大。
這裡需要注意的是,我們在使用鋼筆工具時,拖動的是P5點。其實二階貝賽爾曲線的控制點是其對面的P1點,鋼筆工具這樣設計是當然是因為操作起來比較方便。
好了,對貝賽爾曲線的知識講了那麼多,下面開始實戰了,看在程式碼中,貝賽爾曲線是怎麼來做的。

二、Android中貝賽爾曲線之quadTo

在開篇中,我們已經提到,在Path類中有四個方法與貝賽爾曲線相關,分別是:

[java] view plain copy print ? 在CODE上檢視程式碼片 派生到我的程式碼片
  1. //二階貝賽爾  
  2. public void quadTo(float x1, float y1, float x2, float y2)  
  3. public void rQuadTo(float dx1, float dy1, float dx2, float dy2)  
  4. //三階貝賽爾  
  5. public void cubicTo(float x1, float y1, float x2, float y2,float x3, float y3)  
  6. public void rCubicTo(float x1, float y1, float x2, float y2,float x3, float y3)  
//二階貝賽爾
public void quadTo(float x1, float y1, float x2, float y2)
public void rQuadTo(float dx1, float dy1, float dx2, float dy2)
//三階貝賽爾
public void cubicTo(float x1, float y1, float x2, float y2,float x3, float y3)
public void rCubicTo(float x1, float y1, float x2, float y2,float x3, float y3)
在這四個函式中quadTo、rQuadTo是二階貝賽爾曲線,cubicTo、rCubicTo是三階貝賽爾曲線;我們這篇文章以二階貝賽爾曲線的quadTo、rQuadTo為主,三階貝賽爾曲線cubicTo、rCubicTo用的使用方法與二階貝賽爾曲線類似,用處也比較少,這篇就不再細講了。

1、quadTo使用原理

這部分我們先來看看quadTo函式的用法,其定義如下:

[java] view plain copy print ? 在CODE上檢視程式碼片 派生到我的程式碼片
  1. public void quadTo(float x1, float y1, float x2, float y2)  
public void quadTo(float x1, float y1, float x2, float y2)
引數中(x1,y1)是控制點座標,(x2,y2)是終點座標
大家可能會有一個疑問:有控制點和終點座標,那起始點是多少呢?
整條線的起始點是通過Path.moveTo(x,y)來指定的,而如果我們連續呼叫quadTo(),前一個quadTo()的終點,就是下一個quadTo()函式的起點;如果初始沒有呼叫Path.moveTo(x,y)來指定起始點,則預設以控制元件左上角(0,0)為起始點;大家可能還是有點迷糊,下面我們就舉個例子來看看
我們利用quadTo()來畫下面的這條波浪線:

最關鍵的是如何來確定控制點的位置!前面講過,PhotoShop中的鋼筆工具是二階貝賽爾曲線,所以我們可以利用鋼筆工具來模擬畫出這條波浪線來輔助確定控制點的位置


下面我們來看看這個路徑軌跡中,控制點分別在哪個位置


我們先看P0-P2這條軌跡,P0是起點,假設位置座標是(100,300),P2是終點,假充位置座標是(300,300);在以P0為起始點,P2為終點這條二階貝賽爾曲線上,P1是控制點,很明顯P1大概在P0,P2中間的位置,所以它的X座標應該是200,關於Y座標,我們無法確定,但很明顯的是P1在P0,P2點的上方,也就是它的Y值比它們的小,所以根據鋼筆工具上面的位置,我們讓P1的比P0,P2的小100;所以P1的座標是(200,200)
同理,不難求出在P2,P4這條二階貝賽爾曲線上,它們的控制點P3的座標位置應該是(400,400);P3的X座標是400是,因為P3點是P2,P4的中間點;與P3與P1距離P0-P2-P4這條直線的距離應該是相等的。P1距離P0-P2的值為100;P3距離P2-P4的距離也應該是100,這樣不難算出P3的座標應該是(400,400);
下面開始是程式碼部分了。

2、示例程式碼

(1)、自定義View

我們知道在動畫繪圖時,會呼叫onDraw(Canvas canvas)函式,我們如果重寫了onDraw(Canvas canvas)函式,那麼我們利用canvas在上面畫了什麼,就會顯示什麼。所以我們自定義一個View

[java] view plain copy print ? 在CODE上檢視程式碼片 派生到我的程式碼片
  1. public class MyView extends View {  
  2.     public MyView(Context context) {  
  3.         super(context);  
  4.     }  
  5.   
  6.     public MyView(Context context, AttributeSet attrs) {  
  7.         super(context, attrs);  
  8.     }  
  9.   
  10.     @Override  
  11.     protected void onDraw(Canvas canvas) {  
  12.         super.onDraw(canvas);  
  13.   
  14.         Paint paint = new Paint();  
  15.         paint.setStyle(Paint.Style.STROKE);  
  16.         paint.setColor(Color.GREEN);  
  17.   
  18.         Path path = new Path();  
  19.         path.moveTo(100,300);  
  20.         path.quadTo(200,200,300,300);  
  21.         path.quadTo(400,400,500,300);  
  22.   
  23.         canvas.drawPath(path,paint);  
  24.     }  
  25. }  
public class MyView extends View {
    public MyView(Context context) {
        super(context);
    }

    public MyView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

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

        Paint paint = new Paint();
        paint.setStyle(Paint.Style.STROKE);
        paint.setColor(Color.GREEN);

        Path path = new Path();
        path.moveTo(100,300);
        path.quadTo(200,200,300,300);
        path.quadTo(400,400,500,300);

        canvas.drawPath(path,paint);
    }
}
這裡最重要的就是在onDraw(Canvas canvas)中建立Path的過程,我們在上面已經提到,第一個起始點是需要呼叫path.moveTo(100,300)來指定的,之後後一個path.quadTo的起始點是以前一個path.quadTo的終點為起始點的。有關控制點的位置如何查詢,我們上面已經利用鋼筆工具給大家講解了,這裡就不再細講。
所以,大家在自定義控制元件的時候,要多跟UED溝通,看他們是如何來實現這個效果的,如果是用的鋼筆工具,那我們也可以效仿使用二階貝賽爾曲線來實現。

2、使用MyView

在自定義控制元件以後,然後直接把它引入到主佈局檔案中即可(main.xml)

[html] view plain copy print ? 在CODE上檢視程式碼片 派生到我的程式碼片
  1. <?xml version=“1.0” encoding=“utf-8”?>  
  2. <LinearLayout xmlns:android=“http://schemas.android.com/apk/res/android”  
  3.               android:orientation=“vertical”  
  4.               android:layout_width=“fill_parent”  
  5.               android:layout_height=“fill_parent”>  
  6.   
  7.     <com.harvic.BlogBerzMovePath.MyView  
  8.             android:layout_width=“match_parent”  
  9.             android:layout_height=“match_parent”/>  
  10. </LinearLayout>  
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
              android:orientation="vertical"
              android:layout_width="fill_parent"
              android:layout_height="fill_parent">

    <com.harvic.BlogBerzMovePath.MyView
            android:layout_width="match_parent"
            android:layout_height="match_parent"/>
</LinearLayout>
由於直接做為控制元件顯示,所以MainActivity不需要額外的程式碼即可顯示,MainActivity程式碼如下:
[java] view plain copy print ? 在CODE上檢視程式碼片 派生到我的程式碼片
  1. public class MyActivity extends Activity {  
  2.     /** 
  3.      * Called when the activity is first created. 
  4.      */  
  5.     @Override  
  6.     public void onCreate(Bundle savedInstanceState) {  
  7.         super.onCreate(savedInstanceState);  
  8.         setContentView(R.layout.main);  
  9.     }  
  10. }  
public class MyActivity extends Activity {
    /**
     * Called when the activity is first created.
     */
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);
    }
}
原始碼在文章底部給出
通過這個例子希望大家知道兩點

  • 整條線的起始點是通過Path.moveTo(x,y)來指定的,如果初始沒有呼叫Path.moveTo(x,y)來指定起始點,則預設以控制元件左上角(0,0)為起始點;
  • 而如果我們連續呼叫quadTo(),前一個quadTo()的終點,就是下一個quadTo()函式的起點;

三、手指軌跡

要實現手指軌跡其實是非常簡單的,我們只需要在自定義中攔截OnTouchEvent,然後根據手指的移動軌跡來繪製Path即可。
要實現把手指的移動軌跡連線起來,最簡單的方法就是直接使用Path.lineTo()就能實現把各個點連線起來。

1、實現方式一:Path.lineTo(x,y)

我們先來看看效果圖:

(1)、自定義View——MyView

首先,我們自定義一個View,完整程式碼如下:

[java] view plain copy print ? 在CODE上檢視程式碼片 派生到我的程式碼片
  1. public class MyView extends View {  
  2.   
  3.     private Path mPath = new Path();  
  4.     public MyView(Context context) {  
  5.         super(context);  
  6.     }  
  7.   
  8.     public MyView(Context context, AttributeSet attrs) {  
  9.         super(context, attrs);  
  10.     }  
  11.   
  12.     @Override  
  13.     public boolean onTouchEvent(MotionEvent event) {  
  14.         switch (event.getAction()){  
  15.             case MotionEvent.ACTION_DOWN: {  
  16.                 mPath.moveTo(event.getX(), event.getY());  
  17.                 return true;  
  18.             }  
  19.             case MotionEvent.ACTION_MOVE:  
  20.                 mPath.lineTo(event.getX(), event.getY());  
  21.                 postInvalidate();  
  22.                 break;  
  23.             default:  
  24.                 break;  
  25.         }  
  26.         return super.onTouchEvent(event);  
  27.     }  
  28.   
  29.     @Override  
  30.     protected void onDraw(Canvas canvas) {  
  31.         super.onDraw(canvas);  
  32.         Paint paint = new Paint();  
  33.         paint.setColor(Color.GREEN);  
  34.         paint.setStyle(Paint.Style.STROKE);  
  35.   
  36.         canvas.drawPath(mPath,paint);  
  37.     }  
  38.   
  39.     public void reset(){  
  40.         mPath.reset();  
  41.         invalidate();  
  42.     }  
  43. }  
public class MyView extends View {

    private Path mPath = new Path();
    public MyView(Context context) {
        super(context);
    }

    public MyView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()){
            case MotionEvent.ACTION_DOWN: {
                mPath.moveTo(event.getX(), event.getY());
                return true;
            }
            case MotionEvent.ACTION_MOVE:
                mPath.lineTo(event.getX(), event.getY());
                postInvalidate();
                break;
            default:
                break;
        }
        return super.onTouchEvent(event);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        Paint paint = new Paint();
        paint.setColor(Color.GREEN);
        paint.setStyle(Paint.Style.STROKE);

        canvas.drawPath(mPath,paint);
    }

    public void reset(){
        mPath.reset();
        invalidate();
    }
}
最重要的位置就是在重寫onTouchEvent的位置:
[java] view plain copy print ? 在CODE上檢視程式碼片 派生到我的程式碼片
  1. public boolean onTouchEvent(MotionEvent event) {  
  2.     switch (event.getAction()){  
  3.         case MotionEvent.ACTION_DOWN: {  
  4.             mPath.moveTo(event.getX(), event.getY());  
  5.             return true;  
  6.         }  
  7.         case MotionEvent.ACTION_MOVE:  
  8.             mPath.lineTo(event.getX(), event.getY());  
  9.             postInvalidate();  
  10.             break;  
  11.         default:  
  12.             break;  
  13.     }  
  14.     return super.onTouchEvent(event);  
  15. }  
public boolean onTouchEvent(MotionEvent event) {
    switch (event.getAction()){
        case MotionEvent.ACTION_DOWN: {
            mPath.moveTo(event.getX(), event.getY());
            return true;
        }
        case MotionEvent.ACTION_MOVE:
            mPath.lineTo(event.getX(), event.getY());
            postInvalidate();
            break;
        default:
            break;
    }
    return super.onTouchEvent(event);
}
當用戶點選螢幕的時候,我們呼叫mPath.moveTo(event.getX(), event.getY());然後在使用者移動手指時使用mPath.lineTo(event.getX(), event.getY());將各個點串起來。然後呼叫postInvalidate()重繪;
Path.moveTo()和Path.lineTo()的用法,大家如果看了 《android Graphics(二):路徑及文字》之後,理解起來應該沒什麼難度,但這裡有兩個地方需要注意
第一:有關在case MotionEvent.ACTION_DOWN時return true的問題:return true表示當前控制元件已經消費了下按動作,之後的ACTION_MOVE、ACTION_UP動作也會繼續傳遞到當前控制元件中;如果我們在case MotionEvent.ACTION_DOWN時return false,那麼後序的ACTION_MOVE、ACTION_UP動作就不會再傳到這個控制元件來了。有關動作攔截的知識,後續會在這個系列中單獨來講,大家先期待下吧。
第二:這裡重繪控制元件使用的是postInvalidate();而我們以前也有用Invalidate()函式的。這兩個函式的作用都是用來重繪控制元件的,但區別是Invalidate()一定要在UI執行緒執行,如果不是在UI執行緒就會報錯。而postInvalidate()則沒有那麼多講究,它可以在任何執行緒中執行,而不必一定要是主執行緒。其實在postInvalidate()就是利用handler給主執行緒傳送重新整理介面的訊息來實現的,所以它是可以在任何執行緒中執行,而不會出錯。而正是因為它是通過發訊息來實現的,所以它的介面重新整理可能沒有直接調Invalidate()刷的那麼快。

所以在我們確定當前執行緒是主執行緒的情況下,還是以invalide()函式為主。當我們不確定當前要重新整理頁面的位置所處的執行緒是不是主執行緒的時候,還是用postInvalidate為好;
這裡我是故意用的postInvalidate(),因為onTouchEvent()本來就是在主執行緒中的,使用Invalidate()是更合適的。當我們
有關OnDraw函式就沒什麼好講的,就是把path給畫出來:
[java] view plain copy print ? 在CODE上檢視程式碼片 派生到我的程式碼片
  1. protected void onDraw(Canvas canvas) {  
  2.     super.onDraw(canvas);  
  3.     Paint paint = new Paint();  
  4.     paint.setColor(Color.GREEN);  
  5.     paint.setStyle(Paint.Style.STROKE);  
  6.   
  7.     canvas.drawPath(mPath,paint);  
  8. }  
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    Paint paint = new Paint();
    paint.setColor(Color.GREEN);
    paint.setStyle(Paint.Style.STROKE);

    canvas.drawPath(mPath,paint);
}
最後,我還額外寫了一個重置函式:
[java] view plain copy print ? 在CODE上檢視程式碼片 派生到我的程式碼片
  1. public void reset(){  
  2.     mPath.reset();  
  3.     invalidate();  
  4. }  
public void reset(){
    mPath.reset();
    invalidate();
}

(2)、主佈局

然後看看佈局檔案(mian.xml)

[html] view plain copy print ? 在CODE上檢視程式碼片 派生到我的程式碼片
  1. <?xml version=“1.0” encoding=“utf-8”?>  
  2. <LinearLayout xmlns:android=“http://schemas.android.com/apk/res/android”  
  3.               android:orientation=“vertical”  
  4.               android:layout_width=“fill_parent”  
  5.               android:layout_height=“fill_parent”>  
  6.     <Button  
  7.             android:id=“@+id/reset”  
  8.             android:layout_width=“match_parent”  
  9.             android:layout_height=“wrap_content”  
  10.             android:text=“reset”/>  
  11.   
  12.     <com.harvic.BlogMovePath.MyView  
  13.             android:id=“@+id/myview”  
  14.             android:layout_width=“match_parent”  
  15.             android:layout_height=“match_parent”/>  
  16. </LinearLayout>  
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
              android:orientation="vertical"
              android:layout_width="fill_parent"
              android:layout_height="fill_parent">
    <Button
            android:id="@+id/reset"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="reset"/>

    <com.harvic.BlogMovePath.MyView
            android:id="@+id/myview"
            android:layout_width="match_parent"
            android:layout_height="match_parent"/>
</LinearLayout>
沒什麼難度,就是把自定義控制元件新增到佈局中

(3)、MyActivity

然後看MyActivity的操作:

[java] view plain copy print ? 在CODE上檢視程式碼片 派生到我的程式碼片
  1. public class MyActivity extends Activity {  
  2.     @Override  
  3.     public void onCreate(Bundle savedInstanceState) {  
  4.         super.onCreate(savedInstanceState);  
  5.         setContentView(R.layout.main);  
  6.   
  7.         final MyView myView = (MyView)findViewById(R.id.myview);  
  8.