1. 程式人生 > >Android 高階UI解密 (四) :花式玩轉貝塞爾曲線(波浪、軌跡變換動畫)

Android 高階UI解密 (四) :花式玩轉貝塞爾曲線(波浪、軌跡變換動畫)

講解此UI系列必然少不了一個奇妙數學曲線—–貝塞爾曲線,它目前運用於App的範圍是在太廣了,最初的QQ氣泡拖拽,到個人介面的波浪效果、Loading波浪效果,甚至於軌跡變化的動畫都可以依賴貝塞爾曲線完成,多麼完美的曲線,妙也!

此篇文章並不自己造輪子實現貝塞爾曲線,而是站在巨人的肩膀上,即Android原生為開發者封裝好的相關方法:Path類的quadTo二階貝塞爾曲線繪製方法和cubicTo三階貝塞爾曲線繪製方法。咦,就這麼兩個方法足夠嗎?按理說簡單的繪製足以,但是涉及到複雜的UI效果,例如想要獲取到曲線上的點?只提供二、三階貝塞爾曲線繪製方法,更高階該如何?閱讀完此篇文章,即可揭曉。來領略Path類的王牌之一:貝塞爾的傳說吧~

(此係列文章知識點相對獨立,可分開閱讀,不過筆者建議按照順序閱讀,理解更加深入清晰)

此篇涉及到的知識點如下:

  • 貝塞爾曲線概念、構造、運用
  • 二階、三階貝塞爾曲線程式碼繪製
  • 使用貝塞爾實現波浪、軌跡變換動畫

一. 初識貝塞爾

概念

A Bézier curve (pronounced [bezje] in French) is a parametric curve frequently used in computer graphics and related fields. Generalizations of Bézier curves to higher dimensions are called Bézier surfaces, of which the Bézier triangle is a special case.

這裡寫圖片描述

貝塞爾曲線(在法語中發音為[bezje])是經常用於計算機圖形學和相關領域的引數曲線。 Bézier曲線對更高維的推廣稱為Bézier曲面,其中Bézier三角形是一種特殊情況。簡單來說,貝塞爾曲線就是可以用精確的數學公式來描述的一條曲線。例如上圖所示的曲線,無法用“點”去形容,因為一條曲線上有無數個點。因此這也是貝塞爾曲線的祕密,用數學公式來描述一條曲線。

1. 貝塞爾曲線構造

(1)一階貝塞爾曲線 Linear curves

線性貝塞爾曲線函式中的t可以被認為是描述 B(t) 從P0到P1的距離。

線性Bézier曲線是由兩個點進行控制的,即描述出來的是簡單的直線。例如,當t

= 0.25時,B(t) 是從點P0到P1的四分之一。 當t從0變化到1時,B(t) 描述從P0到P1的直線。

這裡寫圖片描述

(2)二階貝塞爾曲線 Quadratic curves

在二階貝塞爾曲線中除了起點P0和終點P2,還使用到了控制點P1。重點是構造了中間點Q0 和 Q1,描述貝塞爾曲線的B(t)函式隨著Q0 和 Q1而變化。

t從0變化到1時:

  • 點Q0(t) 從P0變化到P1並描述線性Bézier曲線。
  • 點Q1(t) 從P1變化到P2並描述線性Bézier曲線。
  • B(t) 在Q0(t) 到Q1(t) 之間線性插值並描述二次Bézier曲線。

這裡寫圖片描述這裡寫圖片描述

(3)高階貝塞爾曲線 Higher-order curves

對於高階曲線,則需要相應更多的中間點。

對於三階曲線,除了起始點P0和終點P3,有2個控制點P1、P2,構造描述線性Bézier曲線的還有中間點Q0,Q1和Q2,以及描述二次Bézier曲線的點R0和R1。

三階曲線相較於二階曲線,控制點多了一個,中間點多了3個,稍顯複雜,但是曲線的形狀更加豐富。

這裡寫圖片描述這裡寫圖片描述

對於四階曲線,可以構造描述線性Bézier曲線的中間點Q0,Q1,Q2和Q3,描述二次Bézier曲線的點R0,R1和R2以及描述三次Bézier曲線的點S0和S1:

這裡寫圖片描述這裡寫圖片描述

2. 貝塞爾曲線模擬生成

以上貝塞爾曲線階層越高,其影象明顯越複雜,但是都可以用數學公式來描述,可見數學之美。接下來研究重點放在常用的二階、三階貝塞爾曲線,一階就是直線無需深究,而高於三階的貝塞爾曲線稍複雜,程式碼實現其公式難度很大,普通需求一般不涉及,即使涉及到建議進行降階操作,分解成多個二階曲線。

根據貝塞爾曲線模擬生成網站,可線上模擬貝塞爾曲線的生成,即在下圖中指定要繪製幾個點,就可以確定繪製的是二階曲線還是三階,或者高階。

例如下圖中的二階貝塞爾曲線,確定繪製3個點,分別在繪畫板上指定出起點、控制點、終點,即可模擬生成二階貝塞爾曲線:

這裡寫圖片描述

例如下圖中的三階貝塞爾曲線,確定繪製4個點,分別在繪畫板上指定出起點、控制點1、控制點2、終點,即可模擬生成三階貝塞爾曲線:

這裡寫圖片描述

3. 貝塞爾曲線在Android中的用處

  • 所有涉及曲線的影象都可以藉助貝塞爾曲線來實現。例如可以代替VectorDrawable的PathWorking,因為它在相容性上有些問題。
  • 可以將生硬的點到點之間的連線替換成圓滑的連線。
  • 可以模擬更加真實的動畫效果。例如我們常見的波浪、軌跡變換動畫等等。

二. 貝塞爾曲線程式碼實現

1. 二階貝塞爾曲線實現

之前介紹的時候已經講解過二階貝塞爾曲線的構造重點:需要繪製3個點,起點P0、終點P2和控制點P1。程式碼實現步驟如下:

  1. 首先在自定義View的構造方法中初始化好Paint的基本設定
  2. 再實現void onSizeChanged(int w, int h, int oldw, int oldh)方法,它可以確定自定義View的大小。在此方法中初始時確定起點、終點和控制點的座標。(個人設定的佈局為螢幕偏上方,可自行更改)
  3. 接下來可以在onDraw方法中使用Canvas繪製貝塞爾曲線,繪製曲線必定要指定Path移動的路徑。
    • Path成員變數在void onSizeChanged方法中初始化,在onDraw方法中首先呼叫reset()方法,將Path的起點座標定義moveTo(x,y)成起點P0;接著呼叫quadTo(x,y,x2,y2)設定其終點為P2。
    • 呼叫canvas.drawPath(),傳入Path和Paint。
  4. 以上過程已經將貝塞爾曲線繪製完畢,為了更好的觀感體驗,在onDraw方法中新增繪製起點、終點、控制點的圓圈Point和文字提示,再分別繪製兩條起、終點到控制點的直線。整體效果更加清晰
  5. 最後實現boolean onTouchEvent(MotionEvent event)方法,監聽MotionEvent.ACTION_MOVE事件,將手指一動座標賦值給控制點,呼叫invalidate();重新渲染。這樣便可動態繪製二階貝塞爾曲線。

Android已提供繪製二階貝塞爾曲線的方法quadTo(x,y,x2,y2),因此程式碼實現非常簡單基礎,如下:

public class SecondBezierView extends View {
    //起點
    private float mStartPointX;
    private float mStartPointY;
    //終點
    private float mEndPointX;
    private float mEndPointY;
    //控制點
    private float mFlagPointX;
    private float mFlagPointY;

    private Path mPath;
    private Paint mPaintBezier;
    private Paint mPaintFlag;
    private Paint mPaintFlagText;

    public SecondBezierView(Context context) {
        super(context);
    }

    public SecondBezierView(Context context, AttributeSet attrs) {
        super(context, attrs);
        mPaintBezier = new Paint(Paint.ANTI_ALIAS_FLAG);
        mPaintBezier.setStrokeWidth(8);
        mPaintBezier.setStyle(Paint.Style.STROKE);

        mPaintFlag = new Paint(Paint.ANTI_ALIAS_FLAG);
        mPaintFlag.setStrokeWidth(3);
        mPaintFlag.setStyle(Paint.Style.STROKE);

        mPaintFlagText = new Paint(Paint.ANTI_ALIAS_FLAG);
        mPaintFlagText.setStyle(Paint.Style.STROKE);
        mPaintFlagText.setTextSize(20);
    }

    public SecondBezierView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        //初始時確定起點、終點和控制點的座標
        mStartPointX = w / 4;
        mStartPointY = h / 2 - 200;

        mEndPointX = w * 3 / 4;
        mEndPointY = h / 2 - 200;

        mFlagPointX = w / 2;
        mFlagPointY = h / 2 - 300;

        mPath = new Path();
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        mPath.reset();
        mPath.moveTo(mStartPointX, mStartPointY);
        mPath.quadTo(mFlagPointX, mFlagPointY, mEndPointX, mEndPointY);

        canvas.drawPoint(mStartPointX, mStartPointY, mPaintFlag);
        canvas.drawText("起點", mStartPointX, mStartPointY, mPaintFlagText);
        canvas.drawPoint(mEndPointX, mEndPointY, mPaintFlag);
        canvas.drawText("終點", mEndPointX, mEndPointY, mPaintFlagText);
        canvas.drawPoint(mFlagPointX, mFlagPointY, mPaintFlag);
        canvas.drawText("控制點", mFlagPointX, mFlagPointY, mPaintFlagText);
        canvas.drawLine(mStartPointX, mStartPointY, mFlagPointX, mFlagPointY, mPaintFlag);
        canvas.drawLine(mEndPointX, mEndPointY, mFlagPointX, mFlagPointY, mPaintFlag);

        canvas.drawPath(mPath, mPaintBezier);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_MOVE:
                mFlagPointX = event.getX();
                mFlagPointY = event.getY();
                invalidate();
                break;
        }
        return true;
    }
}

演示效果如下:

這裡寫圖片描述

2. 三階貝塞爾曲線實現

在第一大點中的分析可知,三階貝塞爾曲線相較於二階貝塞爾曲線需要繪製的起點、終點、一個控制點外,還多了一個控制點。因此在以上程式碼中需要增加一個控制點,仍舊是在void onSizeChanged(int w, int h, int oldw, int oldh)方法中初始化,後續步驟類似。

其重點有兩個:

  • onDraw方法中呼叫Path指定貝塞爾曲線軌跡,此處不再是quadTo方法,而是cubicTo方法,即需要傳入2個控制點和終點的座標。
  • 重新修改boolean onTouchEvent(MotionEvent event)方法,在二階曲線中只需要修改一個控制點的座標,但此處是2個控制點,我們將它制定為多點觸控,即檢測同時MotionEvent.ACTION_MASK)。在MotionEvent.ACTION_MOVE的狀態下修改控制點的座標時,使用者可能沒有多點觸控,即只使用一根手指在操作,因此需要判斷多點觸控下的第二個控制點的座標修改,此處用一個識別符號來標誌是否出發多點觸控。

(注意多點觸控下呼叫獲取座標方法event.getX(1)需要新增引數)

重點程式碼如下,完整程式碼末尾提供。

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction() & MotionEvent.ACTION_MASK) {
            case MotionEvent.ACTION_POINTER_DOWN:
                isSecondPoint = true;
                break;
            case MotionEvent.ACTION_POINTER_UP:
                isSecondPoint = false;
                break;
            case MotionEvent.ACTION_MOVE:
                mFlagPointOneX = event.getX(0);
                mFlagPointOneY = event.getY(0);
                if (isSecondPoint) {
                    mFlagPointTwoX = event.getX(1);
                    mFlagPointTwoY = event.getY(1);
                }
                invalidate();
                break;
        }
        return true;
    }

效果如下,由於模擬器上無法出發多點觸碰,因此此處演示只有控制點1移動。

這裡寫圖片描述

三. 玩轉貝塞爾曲線

1. 波浪效果

波浪效果的實現關鍵就是藉助波浪的週期性規律和ValueAnimator位移偏量0到波長間重複的變化,通過不斷的位移變化,達到波浪流動的效果。

波浪效果比較常見使用,例如個人介面的頭像展示處。實現該效果有兩種方式,第一種就是運用三角函式公式 sin(x),第二種就是本篇講解的貝塞爾曲線,2個二階的貝塞爾可以實現一個完整波浪,即sin(x)的一個週期,在繪製週期性的波形就不在話下了。

這裡寫圖片描述

(1)實現一個完整波浪

首先實現2個二階貝塞爾曲線,即一個週期的波浪,此處設定為半波長距離為400,連續呼叫兩次quadTo方法即可繪製出,重點程式碼如下:

mPath.moveTo(0, mStartPointY);
mPath.quadTo(200, mStartPointY-300, 400, mStartPointY);
mPath.quadTo(600, mStartPointY+300, 800, mStartPointY);

效果圖如下:

這裡寫圖片描述

接下來的任務是讓波浪填滿螢幕寬度,且流動起來。因此需要弄清兩個問題:螢幕寬度可以容納幾個週期波浪,填滿屏幕後每次波浪位移量多少?通過改變波浪的橫座標來達到讓波浪流動的效果。

  • 前者獲取螢幕寬度計算即可。
  • 後者只需增加二階貝塞爾曲線的起點、終點、控制點橫座標位移量即可,使其呈現出流動的動畫效果。但需要注意當波浪向右側流動時,螢幕左側之外應當也有波形,使得波形準備向右側移動一個弦長的距離時,螢幕左側之外也有一個弦長的波形準備移動進來,不然波浪之間則會斷開。

(2)將波浪填滿螢幕

  1. 首先在自定義View的構造方法中定義一個完整的波浪長度為800,即包括上圓拱和下圓拱部分。
  2. 在初始定義View大小的void onSizeChanged(int w, int h, int oldw, int oldh)方法中獲取螢幕寬度計算填滿螢幕需要幾個完整波長。注意此處在計算時不可直接簡單為mScreenWidth / mWaveLength,首先考慮到除法操作後結果為Double型別,再賦值給int型別count,因此避免舍位帶來的誤差,需要在除法過後加上0.5,而且之前一直在強調,螢幕左側之外也應當有一個弦長的波形準備移動進來,因此再加上1,最後公式為mWaveCount = (int) Math.round(mScreenWidth / mWaveLength + 1.5);
  3. 因此繪製時第一個波形的起點是螢幕外側的 -波形長座標,呼叫mPath。move確定好起點後,迴圈mWaveCount數量繪製波形,每次迴圈繪製一個波形,即之前講過的呼叫兩次quadTo方法。
    1. 繪製第一個半個波形即一個二階貝塞爾曲線時,起點已確定了,控制點的X座標就是-3/4弦長處,Y座標之前自定義為螢幕長度一半,此處還需要加上偏移量60(這裡筆者自定義設定為60,即波浪移動的距離);而終點的X座標就是-1/2弦長處,,Y座標之前自定義為螢幕長度一半。注意以上兩個點的X橫座標還要加上i * mWaveLength,因為在迴圈裡繪製,呈週期變化的波浪。
    2. 繪製第二個 半波形時則更加簡單,第一個控制點的X座標為-1/4弦長處,Y座標則是之前自定義為螢幕長度一半,注意此處是減去偏移量60;而終點X座標為0,Y座標為之前自定義為螢幕長度一半。注意以上兩個點的X橫座標也要加上i * mWaveLength
    3. 迴圈繪製波形完後,最後完善一下繪製兩條直線,如下圖所示的綠線,將這個波浪圖形封閉起來,給此封閉圖形填充色彩,使效果更加明顯。

這裡寫圖片描述這裡寫圖片描述

(3)動畫將波浪動起來

在實現了以上效果後,只剩下一個需求,就是使用動畫ValueAnimation將波浪動起來~並設定一個插值器控制座標點的偏移,實現動畫效果。

  1. 設定成員變數offset,當我們點選自定義View時,產生偏移量觸發動畫效果。
  2. onClick事件中建立屬性動畫,首先建立ValueAnimator物件的位置移動屬性ValueAnimator.ofInt(0, mWaveLength);,再設定該物件的屬性setRepeatCount(ValueAnimator.INFINITE)重複顯示動畫、setInterpolator(new LinearInterpolator())設定線性插值器,最後新增addUpdateListener變化的監聽事件,這也是控制動畫變化的基本方式:在實現的onAnimationUpdate(ValueAnimator valueAnimator)方法中通過valueAnimator.getAnimatedValue()獲取offset的值,呼叫invalidate()重新整理使得自定義View可以產生offset的偏移。

藉助波形圖的週期性和動畫ValueAnimator,設定offset從0到mWaveLength發生變化,使得波形圖一直在向右移動。當它移動完一個波長,即一個週期後,恢復到初始狀態重複移動,從而達到波浪的動畫效果。

注意這個關鍵的offset變數,它的數值時不斷的從0到mWaveLength發生變化,也是波形圖移動的重點,因此將它運用到onDraw繪製方法中,即迴圈裡繪製貝塞爾曲線時到控制點和終點,這兩個點到橫座標都加上offset即可!

(4)完整程式碼與效果展示

以下就是實現波浪效果的完整程式碼,注意實現關鍵就是藉助波浪的週期性規律和ValueAnimator位移偏量0到波長不斷的變化。實現並不複雜,如下:

public class WaveBezierView extends View implements View.OnClickListener {
    private Path mPath;

    private Paint mPaintBezier;

    private int mWaveLength;
    private int mScreenHeight;
    private int mScreenWidth;
    private int mCenterY;
    private int mWaveCount;

    private ValueAnimator mValueAnimator;
    //波浪流動X軸偏移量
    private int mOffsetX;
    //波浪升起Y軸偏移量
    private int mOffsetY;
    private int count = 0;

    public WaveBezierView(Context context) {
        super(context);
    }

    public WaveBezierView(Context context, AttributeSet attrs) {
        super(context, attrs);
        mPaintBezier = new Paint(Paint.ANTI_ALIAS_FLAG);
        mPaintBezier.setColor(Color.LTGRAY);
        mPaintBezier.setStrokeWidth(8);
        mPaintBezier.setStyle(Paint.Style.FILL_AND_STROKE);

        mWaveLength = 800;
    }

    public WaveBezierView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        mPath = new Path();
        setOnClickListener(this);

        mScreenHeight = h;
        mScreenWidth = w;
        mCenterY = h / 2;//設定波浪在螢幕中央處顯示

        //此處多加1,是為了預先載入螢幕外的一個波浪,持續報廊移動時的連續性
        mWaveCount = (int) Math.round(mScreenWidth / mWaveLength + 1.5);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        mPath.reset();
        //Y座標每次繪製時減去偏移量,即波浪升高
        mPath.moveTo(-mWaveLength + mOffsetX, mCenterY);
        //每次迴圈繪製兩個二階貝塞爾曲線形成一個完整波形(含有一個上拱圓,一個下拱圓)
        for (int i = 0; i < mWaveCount; i++) {
            //此處的60是指波浪起伏的偏移量,自定義為60
           /*
            mPath.quadTo(-mWaveLength * 3 / 4 + i * mWaveLength + mOffsetX, mCenterY + 60, -mWaveLength / 2 + i * mWaveLength + mOffset, mCenterY);
            mPath.quadTo(-mWaveLength / 4 + i * mWaveLength + mOffsetX, mCenterY - 60, i * mWaveLength + mOffset, mCenterY);
            */
            //第二種寫法:相對位移
            mPath.rQuadTo(mWaveLength / 4, -60, mWaveLength / 2, 0);
            mPath.rQuadTo(mWaveLength / 4, +60, mWaveLength / 2, 0);

        }
        mPath.lineTo(mScreenWidth, mScreenHeight);
        mPath.lineTo(0, mScreenHeight);
        mPath.close();
        canvas.drawPath(mPath, mPaintBezier);
    }

    @Override
    public void onClick(View view) {
        //設定動畫運動距離
        mValueAnimator = ValueAnimator.ofInt(0, mWaveLength);
        mValueAnimator.setDuration(1000);
        //設定播放數量無限迴圈
        mValueAnimator.setRepeatCount(ValueAnimator.INFINITE);
//        mValueAnimator.setRepeatCount(1);
        //設定線性運動的插值器
        mValueAnimator.setInterpolator(new LinearInterpolator());
        mValueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator valueAnimator) {
                //獲取偏移量,繪製波浪曲線的X橫座標加上此偏移量,產生移動效果
                mOffsetX = (int) valueAnimator.getAnimatedValue();
                count++;

                invalidate();
            }
        });
        mValueAnimator.start();
    }

顯示效果如下:

這裡寫圖片描述

(5)波浪效果拓展—–波浪升起

在實現以上波浪效果後,突發奇想其實有的Loading動畫也是利用波浪,根據百分比計算波浪升起的高度,以波浪佔滿Loading圖形的比例來判斷載入的進度。至於Loading圖形使用Canvas即可解決,類似圓形頭像的實現,有裁剪等多種方式。此處的重點就是使波浪升起,也就是隨著動畫動態修改Path的moveTo方法中的Y座標,方法如下:

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        mPath.reset();
        //判斷波浪升起偏移量
        if(mOffsetY<mCenterY+60){
            mOffsetY += 10;
        }
        //Y座標每次繪製時減去偏移量,即波浪升高
        mPath.moveTo(-mWaveLength + mOffsetX, mCenterY-mOffsetY);

這裡寫圖片描述

細心提示:筆者在簡單修改以上程式碼後,Genymotion模擬器實現了理想中的效果,正得意洋洋喝口水,隨便瀏覽瀏覽知乎segmentFault掘金時,筆記本的小電扇開始瘋狂的旋轉,電腦升溫,頗有三星Note7的feel~筆者忽然發現程式還在執行,雖然螢幕顯示的是全灰色,即波浪蓋過的效果,按理說不應該哎,是電腦日常抽瘋?

細細一想~不對!程式看似已經演示完畢,其實不然!之前設定的動畫播放數次是無限迴圈,雖然螢幕已經全灰色,即mOffsetY的值經過判斷不再增長,但是它依然在無限次繪製最後一次波浪蓋過的全屏灰色!這意味著還在重複渲染相同的UI介面,這種不必要的操作在大量消耗記憶體。筆者恍然大悟,如此愚蠢的問題不被得測試的懟死,哦不,也不一定,他們的KPI因為你而有保障了:)

好了,不廢話了,作為一個合格的猿,波浪慢慢蓋過螢幕這是一個有限的過程,修改也很簡單,在判斷波浪升起偏移量不再增加時,即已經繪製滿屏了就取消動畫!

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        mPath.reset();
        //判斷波浪升起偏移量
        if(mOffsetY<mCenterY+60){
            mOffsetY += 10;
        }else {
            //繪製滿屏時,即升起的波浪沾滿螢幕時,取消動畫重複繪製
            mValueAnimator.cancel();
        }

以上即可減少不必要的UI繪製,哼哼,增加自己的KPI才是硬道理~

(此處只是起一個拋磚引玉的效果,各位想要實現理想的UI效果,還要在提供演示的簡單Demo做許多細節性的修改)

2. 軌跡變換動畫

貝塞爾曲線的另一大優勢就是模擬運動軌跡,再搭配動畫中的插值器對於速度的控制,可以達到更加真實的效果,例如加入購物車時的拋物軌跡動畫效果。

實現此效果最先面臨的問題是Android只為開發者提供了有關貝塞爾曲線繪製的quadTocubicTo方法,但是並沒有任何獲取曲線中點的相關方法,這意味著無法通過貝塞爾曲線去設定物體的運動軌跡,或動畫的運動軌跡!

幸運的是前人已經研究出De Casteljau演算法來獲取貝塞爾曲線上的點,演算法比較複雜,公式如下。後續又有人根據公式推理出簡化版二階、三階貝塞爾曲線的計算方程,通過這些公式可以獲取貝塞爾曲線上任何一個點的座標。

這裡寫圖片描述

(1)程式碼實現De Casteljau演算法工具類

以上得知二階貝塞爾曲線公式後,用程式碼來實現工具類,以二階貝塞爾曲線為例,傳入方法的引數為起始點p0、控制點p1、終止點p2之外,還需要傳入曲線長度比例t,即可獲得t 對應的點座標。程式碼如下:

public class BezierUtil {

    /**
     * 二階貝塞爾曲線B(t) = (1 - t)^2 * P0 + 2t * (1 - t) * P1 + t^2 * P2, t ∈ [0,1]
     *
     * @param t  曲線長度比例
     * @param p0 起始點
     * @param p1 控制點
     * @param p2 終止點
     * @return t對應的點
     */
    public static PointF CalculateBezierPointForQuadratic(float t, PointF p0, PointF p1, PointF p2) {
        PointF point = new PointF();
        float temp = 1 - t;
        point.x = temp * temp * p0.x + 2 * t * temp * p1.x + t * t * p2.x;
        point.y = temp * temp * p0.y + 2 * t * temp * p1.y + t * t * p2.y;
        return point;
    }

    /**
     * 三階貝塞爾曲線B(t) = P0 * (1-t)^3 + 3 * P1 * t * (1-t)^2 + 3 * P2 * t^2 * (1-t) + P3 * t^3, t ∈ [0,1]
     *
     * @param t  曲線長度比例
     * @param p0 起始點
     * @param p1 控制點1
     * @param p2 控制點2
     * @param p3 終止點
     * @return t對應的點
     */
    public static PointF CalculateBezierPointForCubic(float t, PointF p0, PointF p1, PointF p2, PointF p3) {
        PointF point = new PointF();
        float temp = 1 - t;
        point.x = p0.x * temp * temp * temp + 3 * p1.x * t * temp * temp + 3 * p2.x * t * t * temp + p3.x * t * t * t;
        point.y = p0.y * temp * temp * temp + 3 * p1.y * t * temp * temp + 3 * p2.y * t * t * temp + p3.y * t * t * t;
        return point;
    }
}

(2)繪製二階貝塞爾路徑曲線

在後續實現中可以通過Android的API繪製出的貝塞爾曲線和該公式作對比,若此公式正確,則獲取到的點座標都應該落在曲線上。

首先通過Android的API繪製出貝塞爾曲線路徑,此實現異常簡單,即繪製一條二階貝塞爾曲線,效果如下:

這裡寫圖片描述

(3)繪製軌跡變換

如上效果圖,繪製出路徑曲線後,接下來要實現的關鍵是模擬小球從曲線起點滑倒曲線終點處的動畫效果。

1 . 這裡模擬的小球就用Circle實現,為了完善效果,貝塞爾曲線的起、終點座標都繪製一個小圓圈。
2 . 實現ValueAnimator動畫的BezierEvaluator

建立BezierEvaluator類實現TypeEvaluator方法,傳入PointF。建立一個構造方法用來接收控制點PointF,在實現的evaluate(float v, PointF pointF, PointF t1)中檢視一個長度比例引數t、2個Point,正好加上構造方法中的Point,就可以呼叫工具類中的二階貝塞爾函式公式方法

public class BezierEvaluator implements TypeEvaluator<PointF> {

    private PointF mFlagPoint;

    public BezierEvaluator(PointF flagPoint) {
        mFlagPoint = flagPoint;
    }

    @Override
    public PointF evaluate(float v, PointF pointF, PointF t1) {
        return BezierUtil.CalculateBezierPointForQuadratic(v, pointF, mFlagPoint, t1);
    }
}

3 . 一切準備工作完畢,接下來就是設定動畫了:

  • 建立BezierEvaluator並傳入控制點。
  • 呼叫ValueAnimator物件的ofObject方法,傳入BezierEvaluator物件和起點PointF、終點PointF,從而建立ValueAnimator物件。樣我們就自定義好了小球動畫運動的軌跡
  • 接下來的工作就更加簡單了,呼叫ValueAnimator物件的addUpdateListener控制動畫過程中小球的運動軌跡,在建立ValueAnimator的時候已設定好PointF的從起點到終點運動軌跡,再通過(PointF) valueAnimator.getAnimatedValue()獲取運動時的PointF,賦值給模擬小球的座標,呼叫invalidate()重新整理介面。
  • 設定一個AccelerateDecelerateInterpolator加速的插值器。(可自行選擇適合的插值器)
    4 . 最後在onDraw方法中Canvas繪製出移動小球的Circle。

(4)完整程式碼和效果展示

BezierUtil類和BezierEvaluator的程式碼以上已展示,以下展示的是自定義PathBezierView的完整程式碼:

public class PathBezierView extends View implements View.OnClickListener{
    //起點
    private int mStartPointX;
    private int mStartPointY;
    //終點
    private int mEndPointX;
    private int mEndPointY;
    //控制點
    private int mFlagPointX;
    private int mFlagPointY;
    //移動小球
    private int mMovePointX;
    private int mMovePointY;

    private Path mPath;
    private Paint mPaintPath;
    private Paint mPaintCircle;

    public PathBezierView(Context context) {
        super(context);
    }

    public PathBezierView(Context context, AttributeSet attrs) {
        super(context, attrs);
        mPath = new Path();
        mPaintPath = new Paint(Paint.ANTI_ALIAS_FLAG);
        mPaintPath.setStyle(Paint.Style.STROKE);
        mPaintPath.setStrokeWidth(8);
        mPaintCircle = new Paint(Paint.ANTI_ALIAS_FLAG);

        mStartPointX = 100;
        mStartPointY = 100;

        //小球剛開始位置在起點
        mMovePointX = mStartPointX;
        mMovePointY = mStartPointY;

        mEndPointX = 600;
        mEndPointY = 600;

        mFlagPointX = 500;
        mFlagPointY = 0;

        setOnClickListener(this);
    }

    public PathBezierView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        canvas.drawCircle(mStartPointX, mStartPointY, 20, mPaintCircle);
        canvas.drawCircle(mEndPointX, mEndPointY, 20, mPaintCircle);
        canvas.drawCircle(mMovePointX, mMovePointY, 20, mPaintCircle);

        mPath.reset();
        //繪製貝塞爾曲線,即運動路徑
        mPath.moveTo(mStartPointX, mStartPointY);
        mPath.quadTo(mFlagPointX, mFlagPointY, mEndPointX, mEndPointY);
        canvas.drawPath(mPath, mPaintPath);
    }

    @Override
    public void onClick(View view) {
        //建立貝塞爾曲線座標的換算類
        BezierEvaluator evaluator = new BezierEvaluator(new PointF(mFlagPointX, mFlagPointY));
        //指定動畫移動軌跡
        ValueAnimator animator = ValueAnimator.ofObject(evaluator,
                new PointF(mStartPointX, mStartPointY),
                new PointF(mEndPointX, mEndPointY));
        animator.setDuration(600);
        animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator valueAnimator) {
                //改變小球座標,產生運動效果
                PointF pointF = (PointF) valueAnimator.getAnimatedValue();
                mMovePointX = (int) pointF.x;
                mMovePointY = (int) pointF.y;
                //重新整理UI
                invalidate();
            }
        });
        //新增加速插值器,模擬真實物理效果
        animator.setInterpolator(new AccelerateDecelerateInterpolator());
        animator.start();
    }
}

效果展示如下,可以發現小球動畫通過BezierEvaluator計算出的運動軌跡和剛開始繪製的二階貝塞爾曲線完全符合,因此可以證明前人推理出的公式definitely ok~ 效果完成,可以愉快的和pm交代了,hhhh

這裡寫圖片描述

3. 小結

(1)波浪動畫

將波浪動畫拆分成“波浪”、“動畫”來依次實現:

  • 實現波浪圖形可藉助波浪的週期性規律,首先完成一個週期的波浪繪製,可採用sin(x)函式或者貝塞爾曲線。此處選擇後者,2個二階貝塞爾曲線就是一個週期形波浪。注意在將波浪繪製填滿螢幕計算波浪數量時,需要多繪製螢幕外的一個,這是為了後續實現波浪流動動畫時,避免出現不連續的情況。
  • 實現讓波浪流動的效果,實質上就是改變其X橫座標位移量。因此採用ValueAnimator的線形位移即可,在呼叫ofInt確定移動距離時仍利用到波浪的週期性規律,距離就是一個完整波長。最終再設定動畫重複播放,這樣在動畫監聽事件中獲取到不斷從[0, 波長]變換位移量,將此位移量offset相加至onDraw方法中繪製貝塞爾曲線的X橫座標上,呼叫invalidate()方法重新整理介面,即可實現波浪流動的效果。

(2)軌跡變換動畫

此效果的實現關鍵在於動畫,軌跡圖形繪製一個簡單的二階貝塞爾曲線即可實現,可是在使用ValueAnimator動畫時無法像波浪效果一樣線性地去移動貝塞爾曲線此處需要按照貝塞爾曲線的軌跡移動一個模擬Circle,因此重難點就在於如何獲取曲線上的各個點,即描述該曲線的公式。

後續發現前人已經推理出二階、三階貝塞爾曲線公式,此處使用程式碼實現並封裝了一個BezierEvaluator轉換類,呼叫ValueAnimator的ofObject傳入,即可實現我們自定義的動畫運動軌跡,再再監聽動畫改變的事件中修改模擬小球的XY座標,呼叫invalidate()方法重新整理介面,即可實現軌跡運動效果。

若有錯誤,虛心指教~