Android教你一步一步從學習貝塞爾曲線到實現波浪進度條

真丶深紅騎士發表於2019-02-04

前言

大家好,我是深紅騎士,愛開玩笑,技術一渣渣,熱愛鑽研,這篇文章是今年的最後一篇了,首先祝大家在新的一年裡心想事成,諸事順利。今天來學習貝塞爾曲線,之前一直想學,可惜沒時間。什麼是貝塞爾曲線呢?一開始我也是不懂的,當查了很多資料,現在還是不夠了解,其推導公式還是不能深入瞭解。對釋出這曲線的法國工程師皮埃爾·貝塞爾由衷敬佩,貝塞爾曲線,又稱貝茲曲線或者貝濟埃曲線,是應用於二維圖形應用程式的數學曲線.1962年,皮埃爾·貝塞爾運用貝塞爾曲線為汽車的主體進行設計,貝塞爾曲線最初由Paul de Casteljau於1959年運用de Casteljau演算法開發,以穩定的述職方法求出貝塞爾曲線。其實貝塞爾曲線就在我們日常生活中,如一些成熟的點陣圖軟體中:PhotoSHop,Flash5等。在前端開發中,貝塞爾曲線也是無處不在:前端2D或者3D圖形圖示庫都會使用貝塞爾曲線;它可以用來繪製曲線,在svg和canvas中,原生提供的曲線繪製都是用貝塞爾曲線實現的;在css的transition-timing-function屬性,可以使用貝塞爾曲線來描述過渡的緩動計算。

前言貝塞爾演示圖
上面這個動畫是不是很炫,它就是用貝塞爾曲線來實現的。

貝塞爾曲線原理

貝塞爾曲線是用一系列點來控制曲線狀態的,我將這一系列點分為三個點:起點終點控制點。通過改變這些點,貝塞爾曲線就會發生變化。

  • 起點:確定曲線的起點
  • 終點:確定曲線的終點
  • 控制點:確定曲線的控制點

一階曲線原理

一階曲線就是一條直線,只有兩個點,就是起點終點,也就是最終效果就是一條線段。還是直接上圖比較直觀:

一階曲線效果圖
一階公式如下:

一階公式推導
那麼上面的公式是怎麼來的呢?為了方便,我就在紙上寫了,字有點醜,見諒了:

一階公式詳細推導

二階曲線原理

二階曲線由兩個資料點(起始點和終點),一個控制點來描述曲線狀態,如下圖,下面A點是起始點,C是終點,B是控制點。

二階曲線示意圖(一)
紅線AC是怎麼生成的呢?繼續上圖:

二階曲線示意圖(二)
簡單來看連線AB,BC兩條線段,在AB,BC分別取D,E兩點,連線DE。如下圖:

二階曲線示意圖(三)
D在AB線段上從A往B做一階曲線運動,E在BC線段上從B往C做一階曲線運動,而F在DE上做一階曲線運動,那麼F這點就是貝塞爾曲線上的一個點,動態圖如下:

二階曲線示意圖(四)
再簡單理解就是:二階貝塞爾曲線就是起點終點不斷變化的一階貝塞爾曲線。二階公式如下:

二階公式
那麼上面這個公司怎麼推匯出來的呢?同樣為了方便,我就在紙上寫了:

二階公式推導

三階曲線原理

三階曲線其實就是由兩個資料點(起始點和終點),兩個控制點來描述曲線的狀態,如下圖,下面A是起始點,D是終點,B和C是控制點。

三階曲線示意圖(一)
動態圖如下:

三階曲線示意圖(二)
可以這麼理解,兩個資料點和控制點不斷變化的二階貝塞爾曲線,即拆分為p0p1p2和p1p2p3兩個二階貝塞爾曲線。三階公式如下:

三階公式如下
那麼上面這個公式是怎麼推匯出來的呢?直接上圖:

三階曲線推導圖
四階,五階的效果圖和推導公式就不上了,原理是一樣的。通過上面一階,二階,三階的推導可以發現這樣一個規律:沒N階貝塞爾曲線都可以拆分為兩個N-1階,和高數中二項式展開一樣,就是階數越高,控制點之間就會越近,繪製的曲線就會更加絲滑。通用公式如下:

n階曲線通用公式
把貝塞爾曲線原理弄懂了,下面就可以用來做實際性的東西了。

Android中的貝塞爾曲線

在Android中,Path類中有四個方法與貝塞爾曲線相關的,也就是已經封裝了關於貝塞爾曲線的函式,開發者直接呼叫即可:

    //二階貝賽爾
    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為詳細說明。

quadTo原理

先看看quadTo函式的定義:

/**
     * Add a quadratic bezier from the last point, approaching control point
     * (x1,y1), and ending at (x2,y2). If no moveTo() call has been made for
     * this contour, the first point is automatically set to (0,0).
     *
     * @param x1 The x-coordinate of the control point on a quadratic curve
     * @param y1 The y-coordinate of the control point on a quadratic curve
     * @param x2 The x-coordinate of the end point on a quadratic curve
     * @param y2 The y-coordinate of the end point on a quadratic curve
     */
    public void quadTo(float x1, float y1, float x2, float y2) {
        isSimplePath = false;
        nQuadTo(mNativePath, x1, y1, x2, y2);
    }
複製程式碼

看上面的註釋可以知道:(x1,y1)是控制點,(x2,y2)是終點座標,怎麼沒有起點的座標呢?作為Android開發者都知道,一條線段的起始點都是通過Path.move(x,y)來指定的。如果連續呼叫quadTo函式,那麼前一個quadTo的終點就是下一個quadTo函式的起始點,如果初始化沒有呼叫Path.moveTo(x,y)來指定起始點,那麼控制元件檢視就會以左上角(0,0)為起始點,還是直接上例子描述。 下面實現繪製下面以下效果圖:

效果圖一
下面先通過PhotoShop來模擬畫出上面這條軌跡的輔助控制點的位置:

photoShop演示圖
下面通過草圖分析確定起始點,終點,控制點的位置,注意,下面的分析圖位置不是很準備,只是為了確定控制點的位置。

開始路徑分析圖
先看p0-p1這條路徑,是以p0為起始點,p2為終點,p1為控制點。起始的座標設定為(200,400),終點的座標設定(400,400),控制點是在p0,p1的上方,因此縱座標y的值比兩點都要小,橫座標的位置是在p0和p2的中間。那麼p1座標設定為(300,300);同理,在p2-p4的這條二階貝塞爾曲線上,控制點p3的座標位置應該是(500,500),因為p0-p2,p2-p4這兩條貝塞爾曲線是對稱的。

示例程式碼

public class PathView extends View {


    //畫筆
    private Paint paint;
    //路徑
    private Path path;

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

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


    //重寫onDraw方法
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        paint.setStyle(Paint.Style.STROKE);
        //線條寬度
        paint.setStrokeWidth(10);
        paint.setColor(Color.RED);
        //設定起始點的位置為(200,400)
        path.moveTo(200,400);
        //線條p0-p2控制點(300,300) 終點位置(400,400)
        path.quadTo(300,300,400,400);
        //線條p2-p4控制點(500,500) 終點位置(600,400)
        path.quadTo(500,500,600,400);
        canvas.drawPath(path, paint);

    }


    private void init() {
        paint = new Paint();
        path = new Path();
    }
}
複製程式碼

佈局檔案如下:

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#000000">



    <Button
        android:id="@+id/btn_reset"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        android:layout_marginTop="10dp"
        android:text="清空路徑"
        />

    <com.example.okhttpdemo.PathView
        android:id="@+id/path_view"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:layout_constraintTop_toBottomOf="@id/btn_reset"
        android:background="#000000"/>


</android.support.constraint.ConstraintLayout>
複製程式碼

效果圖如下圖所示:

線段一效果圖
下面把path.moveTo(200,400);註釋,再看看效果:

去掉指定起始點的效果圖
通過上面的簡單例子,可以得出以下兩點:

  • 當連續呼叫quadTo函式時,前一個quadTo函式的終點就是呼叫下一個quadTo函式的起始點。
  • 貝塞爾曲線的起點是通過Path.moveTo(x,y)來指定的,如果一開始沒有呼叫Path.move(x,y),則會取控制元件的左上角(0,0)作為起點。

Path.lineTo和Path.quadTo的區別

下面來看看Path.lineTo和Path.quadTo的區別,Path.lineTo是連線直線,是連線上一個點到當前點的之間的直線,下面來實現繪製手指在螢幕上所走的路徑,也不難就在上面的基礎上增加onTouchEvent方法即可,程式碼如下:

public class PathView extends View {


    //畫筆
    private Paint paint;
    //路徑
    private Path path;

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

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


    //重寫onDraw方法
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        paint.setStyle(Paint.Style.STROKE);
        //線條寬度
      //  paint.setStrokeWidth(10);
        paint.setColor(Color.RED);
        canvas.drawPath(path, paint);

    }


    private void init() {
        paint = new Paint();
        path = new Path();
    }


    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN: {
                Log.d("ssd","觸發按下");
                path.moveTo(event.getX(), event.getY());
                return true;
            }
            case MotionEvent.ACTION_MOVE:
                Log.d("ssd","觸發移動");
                path.lineTo(event.getX(), event.getY());
                invalidate();
                break;
            default:
                break;

        }
        return super.onTouchEvent(event);
    }


    public void reset() {
        path.reset();
        invalidate();
    }



}
複製程式碼

直接上效果圖:

手指軌跡Path.LineTo實現
當使用者點選螢幕時,首先觸發的是MotionEvent.DOWN這個條件,然後呼叫path.move(event.getX(),event.getY()),當使用者移動手指時,就用path.lineTo(event.getX,event.getY())將各個點連線起來,然後呼叫invalidate重新繪製。這裡簡單說一下在MotionEvent.ACTION_DOWN為什麼要返回return truereturn true表示當前的控制元件已經消費了按下事件,剩下的ACTION_UPACTION_MOVE都會被執行;如果在case MotionEvent.ACTION_DOWN下返回return false,後續的MOTION_MOVEMMOTION_UP 都不會被接收到,因為沒有消費ACTION_DOWN,系統就會認為ACTION_DOWN沒有發生過,所以ACTION_MOVEACTION_UP就不能捕獲,下面把圖放大仔細看:

放大的圖細節
把C放大後,很明顯看出,這個C字不是很平滑,像是很多一折一折的線段構成,出現這樣的原因也很簡單分析出來,因為這個C字是由各個不同點之間連線構成的,之間就沒有平滑過渡,如果橫縱座標變化劇烈時,更加突出有摺痕效果。如果要解決這問題,這時候二階曲線的作用體現出來了。

線段轉折分析
上圖中,有三個黑點連成兩條直線,從兩個線段可以看出,如果使用Path.lineTo的時候,是直接把觸控點p0,p1,p2連線起來,那麼現在要實現這個三個點之間的流暢過渡,我想到的就是把這兩條線的中點分別作為起點和終點,把連線這兩條線段的點(p1)作為控制點,那這樣就能解決上面摺痕的問題,直接上效果圖:

解決摺痕點
但是上面會有這樣一個問題:當你繪製二階曲線的時候,結束的時候,最開始那段線段的前半部分也就是p0-p3,最後那段線段的後半部分也就是p4-p2不會繪製出來。其實這兩端距離可以忽略不計,因為手指滑動的時候,所產生的點與點之間的距離是很小的,因此p0-p3,p4-p2的距離可以忽略不算了。每個圖形中,肯定有很多點共同連線線段,而現在就是將兩個線段的中間做為二階曲線的起點和終點,把線段與線段之間的轉折點做為控制點,這樣來組成平滑的連線。理論上應該可以,那就下面之間敲程式碼:

 @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN: {
                Log.d("ssd","觸發按下");
                path.moveTo(event.getX(), event.getY());
                //儲存這個點的座標
                mBeforeX = event.getX();
                mBeforeY = event.getY();
                return true;
            }
            case MotionEvent.ACTION_MOVE:
                Log.d("ssd","觸發移動");
                //移動的時候繪製二階曲線
                //終點是線段的中點
                endX = (mBeforeX + event.getX()) / 2;
                endY = (mBeforeY + event.getY()) / 2;
                //繪製二階曲線
                path.quadTo(mBeforeX,mBeforeY,endX,endY);
                //然後更新前一點的座標
                mBeforeX = event.getX();
                mBeforeY = event.getY();
                invalidate();
                break;
            default:
                break;

        }
        return super.onTouchEvent(event);
    }

複製程式碼

這裡簡單說明一下,在ACTION_DOWN的時候,先呼叫path.moveTo(event.getX(), event.getY());設定曲線的初始位置就是手指觸屏的位置,上面也解釋瞭如果不呼叫moveTo(event.getX(), event.getY())的話,那麼繪製點就會從控制元件的(0,0)開始。用mBeforeXmBeforeY記錄手指移動的前一個橫縱座標,而這個點是做控制點,最後返回return true為了讓ACTION_MOVEACTION_UP向本控制元件傳遞。下面說說在ACTION_MOVE方法的邏輯處理,首先是確定結束點,上面也說了結束點是線段的中間位置,所以用了兩條公式來endX = (mBeforeX + event.getX()) / 2;endY = (mBeforeY + event.getY()) / 2;求這個中間位置的橫縱座標,而控制點就是上個手指觸控螢幕的位置,後面就是更新前一個手指座標。這裡注意一下,上面也說了當連續呼叫quardTo的時候,第一個起始點是Path.moveTo(x,y)來設定的,其他部分,前面呼叫quadTo的終點是下一個quard的起點,這裡所說的起始點就是上一個線段的中間點。上面的邏輯用一句話表示:把各個線段的中間點作為起始點和終點,把前一個手指位置作為控制點,最終效果如下:

改良後的C圖
可以看到通過quadT實現的曲線會更順滑。

Path.rQuadTo原理

直接看這個函式的說明:

/**
     * Add a quadratic bezier from the last point, approaching control point
     * (x1,y1), and ending at (x2,y2). If no moveTo() call has been made for
     * this contour, the first point is automatically set to (0,0).
     *
     * @param x1 The x-coordinate of the control point on a quadratic curve
     * @param y1 The y-coordinate of the control point on a quadratic curve
     * @param x2 The x-coordinate of the end point on a quadratic curve
     * @param y2 The y-coordinate of the end point on a quadratic curve
     */
    public void quadTo(float x1, float y1, float x2, float y2) {
        isSimplePath = false;
        nQuadTo(mNativePath, x1, y1, x2, y2);
    }
複製程式碼
  • x1:控制點的X座標,表示相對於上一個終點X座標的位移值,可以為負值,正值表示相加,負值表示相減
  • x2:控制點的Y座標,表示相對於上一個終點Y座標的位移值,可以為負值,正值表示相加,負值表示相減
  • x2:終點的X座標,表示相對於上一個終點X座標的位移值,可以為負值,正值表示相加,負值表示相減
  • y2:終點的Y座標,表示相對一上一個終點Y座標的位移值,可以為負值,正值表示相加,負值表示相減 這麼說可能不理解,下面還是直接舉例子: 如果上一個終點座標是(100,200),如果這時候呼叫rQuardTo(100,-100,200,200),得到的控制點座標是(100 + 100,200 - 100 )就是(200,100),得到的終點座標是(100 + 200,200 + 200)就是(300,400),下面兩段是相等的:
path.moveTo(100,200);
path.quadTo(200,100,300,400);
複製程式碼
path.moveTo(100,200);
path.rQuadTo(100,-100,200,200);
複製程式碼

在上面中,用quadTo實現了一個波浪線,下圖:

波浪線效果圖
下面是上面用quadTo實現的程式碼:

//重寫onDraw方法
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        paint.setStyle(Paint.Style.STROKE);
        //線條寬度
        paint.setStrokeWidth(10);
        paint.setColor(Color.RED);
        //設定起始點的位置為(200,400)
        path.moveTo(200,400);
        //線條p0-p2控制點(300,300) 終點位置(400,400)
        path.quadTo(300,300,400,400);
        //線條p2-p4控制點(500,500) 終點位置(600,400)
        path.quadTo(500,500,600,400);
        canvas.drawPath(path, paint);

    }
複製程式碼

下面就用rQuadTo來實現這個波浪線,先上分析圖:

開始路徑分析圖
程式碼如下:

 //重寫onDraw方法
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        paint.setStyle(Paint.Style.STROKE);
        //線條寬度
        paint.setStrokeWidth(10);
        paint.setColor(Color.RED);
        //設定起始點的位置為(200,400)
        path.moveTo(200,400);
        //線條p0-p2控制點(300,300) 終點座標位置(400,400)
        path.rQuadTo(100,-100,200,0);
        //線條p2-p4控制點(500,500) 終點座標位置(600,400)
        path.rQuadTo(100,100,200,0);
        canvas.drawPath(path, paint);

    }
複製程式碼

第一行:path.rQuadTo(100,-100,200,0);這個一行程式碼是基於(200,400)這個點來計算曲線p0-p2的控制點和終點座標。

  • 控制點X座標 = 上一個終點的X座標 + 控制點X位移值 = 200 + 100 = 300;
  • 控制點Y座標 = 上一個終點的Y座標 + 控制點Y位移值 = 400 - 100 = 300;
  • 終點X座標 = 上一個終點的X座標 + 終點X的位移值 = 200 + 200 = 400;
  • 終點Y座標 = 上一個終點的Y座標 + 終點Y的位移值 = 400 + 0 = 400; 這句和path.quadTo(300,300,400,400)是等價的。

那麼第一條曲線就容易繪製出來了,並且第一條曲線的終點也知道了是(400,400),那麼第二句path.rQuadTo(100,100,200,0)是基於這個終點(400,400)來計算第二條曲線的控制點和終點。

  • 控制點X座標 = 上一個終點的X座標 + 控制點X位移值 = 400 + 100 = 500;
  • 控制點Y座標 = 上一個終點的Y座標 + 控制點Y位移值 = 400 + 100 = 500
  • 終點X座標 = 上一個終點的X座標 + 終點X的位移值 = 400 + 200 = 600;
  • 終點Y座標 = 上一個終點的Y座標 + 終點Y的位移值 = 400 + 0 = 400;

其實這句path.rQuadTo(100,100,200,0);是和path.quadTo(500,500,600,400);相等的,實際執行的效果圖也和用quadTo方法繪製的一樣,通過這個例子,可以知道quadTo這個方法的引數都是實際結果的座標,而rQuadTo這個方法的引數是以上一個終點位置為基準來做位移的。

實現封閉波浪

下面要實現以下效果:

滿屏波浪效果

實現靜態封閉波浪

對應程式碼如下:

  //重寫onDraw方法
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        path.reset();
        //設定填充繪製
        paint.setStyle(Paint.Style.FILL_AND_STROKE);
        //線條寬度
        //paint.setStrokeWidth(10);
        paint.setColor(Color.RED);
        int control = waveLength / 2;
        //首先確定初始狀態的起點(-400,1200)
        path.moveTo(-waveLength,origY);
        //因為這個整個波浪的的寬度是View寬度加上左右各一個波長
        for(int i = -waveLength;i <= getWidth() + waveLength;i += waveLength){
            path.rQuadTo(control / 2,-70,control,0);
            path.rQuadTo(control / 2,70,control,0);
        }
        path.lineTo(getWidth(),getHeight());
        path.lineTo(0,getHeight());
        path.close();
        canvas.drawPath(path, paint);

    }
複製程式碼

下面一行一行分析:

        //首先確定初始狀態的起點(-400,1200)
        path.moveTo(-waveLength,origY);
複製程式碼

首先將Path起始位置向左移一個波長,為了就是後面實現的位移動畫,然後利用迴圈來畫出螢幕所容下的所有波浪:

 for(int i = -waveLength;i <= getWidth() + waveLength;i += waveLength){
            path.rQuadTo(control / 2,-70,control,0);
            path.rQuadTo(control / 2,70,control,0);
        }
複製程式碼

這裡我簡單說一下,path.rQuadTo(control / 2,-70,control,0);迴圈裡的第一行畫的是一個波長的前半部分,下面把數值放進去就很容易理解了,因為waveLength是400,所以control = waveLength / 2就是200,而path.rQuadTo(control / 2,-70,control,0)就是path.rQuadTo(100,-70,200,0),而path.rQuadTo(control / 2,70,control,0)就是path.rQuadTo(100,70,200,0),上面說過rQuadTo的用法了,就不再敘述,下面直接上分析圖,下面只是分析最左邊的第一個波浪起始點,控制點的座標,其餘波浪只是通過迴圈繪製,就不分析了:

分析左邊第一個波浪
因為需要連續呼叫兩次rQuadTo方法才能繪製出一個完整的波浪,所以上面分析需要確定五個點的位置。這裡注意,上面圖左右有一條線段連線底部,形成封閉圖形,因為要填充內部,所以要封閉繪製paint.setStyle(Paint.Style.FILL_AND_STROKE);。當波浪繪製完成時,path點會在A點,然後用path.lineTo(getWidth(),getHeight());連線A,B點,再呼叫path.lineTo(0,getHeight());連線B,C點,最後呼叫path.close();連線初始點就是連線C和起始點,這樣滿橫屏的波浪就繪製完成了。

實現位移動畫的波浪

下面實現左右上下位移動畫,這就會有一點點進度條的感覺,我的做法很簡單,因為一開始在View的左邊多畫了一個波浪,也就是說,將起始點向右邊移動,並且要移動一個波浪的長度就可以讓波紋重合,然後不斷迴圈即可,簡單來講就是,動畫移動的距離是一個波浪的長度,當移動到最大的距離時設定不斷迴圈,就會重新繪製波浪的初始狀態。

    /**
     * 動畫位移方法
     */
    public void startAnim(){
        //建立動畫例項
        ValueAnimator moveAnimator = ValueAnimator.ofInt(0,waveLength);
        //動畫的時間
        moveAnimator.setDuration(2500);
        //設定動畫次數  INFINITE表示無限迴圈
        moveAnimator.setRepeatCount(ValueAnimator.INFINITE);
        //設定動畫插值
        moveAnimator.setInterpolator(new LinearInterpolator());
        //新增監聽
        moveAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                moveDistance = (int)animation.getAnimatedValue();
                invalidate();
            }
        });
        //啟動動畫
        moveAnimator.start();
    }
複製程式碼

動畫的位移距離是一個波浪的長度,並將位移的距離儲存到moveDistance中,然後開始的時候,在moveTo加上這個距離,就可以了,完整程式碼如下:

  //重寫onDraw方法
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        //把路線清除 重新繪製 一定要加上 不然是矩形
        path.reset();
        //設定填充繪製
        paint.setStyle(Paint.Style.FILL_AND_STROKE);
        //線條寬度
        //paint.setStrokeWidth(10);
        paint.setColor(Color.RED);
        int control = waveLength / 2;
        //首先確定初始狀態的起點(-400,1200)
        path.moveTo(-waveLength + moveDistance,origY);
        for(int i = -waveLength;i <= getWidth() + waveLength;i += waveLength){
            path.rQuadTo(control / 2,-70,control,0);
            path.rQuadTo(control / 2,70,control,0);
        }
        path.lineTo(getWidth(),getHeight());
        path.lineTo(0,getHeight());
        path.close();
        canvas.drawPath(path, paint);

    }
複製程式碼

效果如下:

橫向波浪
上面只是新增橫向移動,下面新增垂直的移動,我這邊為了方便,垂直移動距離跟橫向距離一樣,很簡單,把初始縱座標同樣減去移動距離,因為是向上移動,所以是要減path.moveTo(-waveLength + moveDistance,origY - moveDistance);,最後呼叫以下程式碼:

        pathView = findViewById(R.id.path_view);
        pathView.startAnim();
複製程式碼

效果如上上上圖。經過上面,自己對貝塞爾曲線由初步的瞭解,下面就實現波浪形進度條。

實現波浪進度條

學到了上面的基本知識,那下面就實現一個小例子,就是圓形波浪進度條,最終效果在文章最底部,慣例下面就一步一步來實現。

繪製一段波浪

先繪製一段滿屏的波浪線,繪製原理就不詳細講了,直接上程式碼:

/**
 * Describe : 實現圓形波浪進度條
 * Created by Knight on 2019/2/1
 * 點滴之行,看世界
 **/
public class CircleWaveProgressView extends View {

    //繪製波浪畫筆
    private Paint wavePaint;
    //繪製波浪Path
    private Path wavePath;
    //波浪的寬度
    private float waveLength;
    //波浪的高度
    private float waveHeight;
    public CircleWaveProgressView(Context context) {
        this(context,null);
    }

    public CircleWaveProgressView(Context context,  @Nullable AttributeSet attrs) {
        this(context, attrs,0);
    }

    public CircleWaveProgressView(Context context,  @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(context);
    }


    /**
     * 初始化一些畫筆路徑配置
     * @param context
     */
    private void  init(Context context){
        //設定波浪寬度
        waveLength = Density.dip2px(context,25);
        //設定波浪高度
        waveHeight = Density.dip2px(context,15);
        wavePath = new Path();
        wavePaint = new Paint();
        wavePaint.setColor(Color.parseColor("#ff7c9e"));
        //設定抗鋸齒
        wavePaint.setAntiAlias(true);
    }


    @Override
    protected void onDraw(Canvas canvas){
        super.onDraw(canvas);
        //繪製波浪線
        canvas.drawPath(paintWavePath(),wavePaint);
    }

    /**
     * 繪製波浪線
     *
     * @return
     */
    private Path paintWavePath(){
        //要先清掉路線
        wavePath.reset();
        //起始點移至(0,waveHeight)
        wavePath.moveTo(0,waveHeight);
        for(int i = 0;i < getWidth() ;i += waveLength){
             wavePath.rQuadTo(waveLength / 2,waveHeight,waveLength,0);
             wavePath.rQuadTo(waveLength / 2,-waveHeight,waveLength,0);
        }
        return wavePath;
    }

}

複製程式碼

xml佈局檔案:

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
>
    <com.example.progressbar.CircleWaveProgressView
        android:id="@+id/circle_progress"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        />

</android.support.constraint.ConstraintLayout>
複製程式碼

實際效果如下:

波浪進度條(一)
下面繪製封閉波浪,效果分析圖如下:

繪製封閉靜態波浪

因為圓形進度框中的波浪是隨著進度的增加而不斷上升的,所以波浪是填充物,先繪製波浪,然後用path.lineTopath.close來連線封閉起來,構成一個填充圖形,分析如下圖:

波浪進度分析條二
繪製順序是p0-p1-p2-p3,程式碼如下:

public class CircleWaveProgressView extends View {

    //繪製波浪畫筆
    private Paint wavePaint;
    //繪製波浪Path
    private Path wavePath;
    //波浪的寬度
    private float waveLength;
    //波浪的高度
    private float waveHeight;
    //波浪組的數量 一個波浪是一低一高
    private int waveNumber;
    //自定義View的波浪寬高
    private int waveDefaultSize;
    //自定義View的最大寬高 就是比波浪高一點
    private int waveMaxHeight;

    public CircleWaveProgressView(Context context) {
        this(context,null);
    }

    public CircleWaveProgressView(Context context,  @Nullable AttributeSet attrs) {
        this(context, attrs,0);
    }

    public CircleWaveProgressView(Context context,  @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(context);
    }


    /**
     * 初始化一些畫筆路徑配置
     * @param context
     */
    private void  init(Context context){
        //設定波浪寬度
        waveLength = Density.dip2px(context,25);
        //設定波浪高度
        waveHeight = Density.dip2px(context,15);
        //設定自定義View的寬高
        waveDefaultSize = Density.dip2px(context,250);
        //設定自定義View的最大寬高
        waveMaxHeight = Density.dip2px(context,300);
        //Math.ceil(a)返回求不小於a的最小整數
        // 舉個例子:
        // Math.ceil(125.9)=126.0
        // Math.ceil(0.4873)=1.0
        // Math.ceil(-0.65)=-0.0
        //這裡是調整波浪數量 就是View中能容下幾個波浪 用到ceil就是一定讓View完全能被波浪佔滿 為迴圈繪製做準備 分母越小就約精準
        waveNumber = (int) Math.ceil(Double.parseDouble(String.valueOf(waveDefaultSize / waveLength / 2)));
        wavePath = new Path();
        wavePaint = new Paint();
        //設定顏色
        wavePaint.setColor(Color.parseColor("#ff7c9e"));
        //設定抗鋸齒
        wavePaint.setAntiAlias(true);

    }


    @Override
    protected void onDraw(Canvas canvas){
        super.onDraw(canvas);
        //繪製波浪線
        canvas.drawPath(paintWavePath(),wavePaint);
        Log.d("ssd",getWidth()+"");
    }

    /**
     * 繪製波浪線
     *
     * @return
     */
    private Path paintWavePath(){
        //要先清掉路線
        wavePath.reset();
        //起始點移至(0,waveHeight)
        wavePath.moveTo(0,waveMaxHeight - waveDefaultSize);
        //最多能繪製多少個波浪
        //其實也可以用 i < getWidth() ;i+=waveLength來判斷 這個沒那麼完美
        //繪製p0 - p1 繪製波浪線
        for(int i = 0;i < waveNumber ;i ++){
             wavePath.rQuadTo(waveLength / 2,waveHeight,waveLength,0);
             wavePath.rQuadTo(waveLength / 2,-waveHeight,waveLength,0);
        }
        //連線p1 - p2
        wavePath.lineTo(waveDefaultSize,waveDefaultSize);
        //連線p2 - p3
        wavePath.lineTo(0,waveDefaultSize);
        //連線p3 - p0
        wavePath.lineTo(0,waveMaxHeight - waveDefaultSize);
        //封閉起來填充
        wavePath.close();
        return wavePath;
    }

複製程式碼

測量自適應View的寬高

在上面中,發現一個問題,就是寬和高都在初始化方法init中定死了,一般來講檢視View的寬高都是在xml檔案中定義或者類檔案中定義的,那麼就要重寫View的onMeasure方法:

 @Override
    protected void onMeasure(int widthMeasureSpec,int heightMeasureSpec){
         super.onMeasure(widthMeasureSpec,heightMeasureSpec);
        int height = measureSize(waveDefaultSize, heightMeasureSpec);
        int width = measureSize(waveDefaultSize, widthMeasureSpec);
        //獲取View的最短邊的長度
        int minSize = Math.min(height,width);
        //把View改為正方形
        setMeasuredDimension(minSize,minSize);
        //waveActualSize是實際的寬高
        waveActualSize = minSize;
        //Math.ceil(a)返回求不小於a的最小整數
        // 舉個例子:
        // Math.ceil(125.9)=126.0
        // Math.ceil(0.4873)=1.0
        // Math.ceil(-0.65)=-0.0
        //這裡是調整波浪數量 就是View中能容下幾個波浪 用到ceil就是一定讓View完全能被波浪佔滿 為迴圈繪製做準備 分母越小就約精準
        waveNumber = (int) Math.ceil(Double.parseDouble(String.valueOf(waveActualSize / waveLength / 2)));


    }

    /**
     * 返回指定的值
     * @param defaultSize 預設的值
     * @param measureSpec 模式
     * @return
     */
    private int measureSize(int defaultSize,int measureSpec) {
        int result = defaultSize;
        int specMode = View.MeasureSpec.getMode(measureSpec);
        int specSize = View.MeasureSpec.getSize(measureSpec);

        //View.MeasureSpec.EXACTLY:如果是match_parent 或者設定定值就
        //View.MeasureSpec.AT_MOST:wrap_content
        if (specMode == View.MeasureSpec.EXACTLY) {
            result = specSize;
        } else if (specMode == View.MeasureSpec.AT_MOST) {
            result = Math.min(result, specSize);
        }
        return result;
    }
複製程式碼

上面就很簡單了,就是增加了一個View的實際寬高變數waveActualSize,讓程式碼擴充套件性更強和精確到更高。

繪製波浪上升

下面實現波浪高度隨著進度變化而變化,當進度增加時,波浪高度增高,當進度減少時,波浪高度減少,其實很簡單,也就是p0-p3,p1-p2的高度根據進度變化而變化,並增加動畫,程式碼增加如下:

    //當前進度值佔總進度值的佔比
    private float currentPercent;
    //當前進度值
    private float currentProgress;
    //進度的最大值
    private float maxProgress;
    //動畫物件
    private WaveProgressAnimat waveProgressAnimat;
    
     /**
     * 初始化一些畫筆路徑配置
     * @param context
     */
    private void  init(Context context){
        //......
        //佔比一開始設定為0
        currentPercent = 0;
        //進度條進度開始設定為0
        currentProgress = 0;
        //進度條的最大值設定為100
        maxProgress = 100;
        //動畫例項化
        waveProgressAnimat = new WaveProgressAnimat();

    }
    
       /**
     * 繪製波浪線
     *
     * @return
     */
    private Path paintWavePath(){
        //要先清掉路線
        wavePath.reset();
        //起始點移至(0,waveHeight) p0 -p1 的高度隨著進度的變化而變化
        wavePath.moveTo(0,(1 - currentPercent) * waveActualSize);
        //最多能繪製多少個波浪
        //其實也可以用 i < getWidth() ;i+=waveLength來判斷 這個沒那麼完美
        //繪製p0 - p1 繪製波浪線
        for(int i = 0;i < waveNumber ;i ++){
             wavePath.rQuadTo(waveLength / 2,waveHeight,waveLength,0);
             wavePath.rQuadTo(waveLength / 2,-waveHeight,waveLength,0);
        }
        //連線p1 - p2
        wavePath.lineTo(waveActualSize,waveActualSize);
        //連線p2 - p3
        wavePath.lineTo(0,waveActualSize);
        //連線p3 - p0 p3-p0d的高度隨著進度變化而變化
        wavePath.lineTo(0,(1 - currentPercent) * waveActualSize);
        //封閉起來填充
        wavePath.close();
        return wavePath;
    }
    
      //新建一個動畫類
    public class WaveProgressAnimat extends Animation{


        //在繪製動畫的過程中會反覆的呼叫applyTransformation函式,
        // 每次呼叫引數interpolatedTime值都會變化,該引數從0漸 變為1,當該引數為1時表明動畫結束
        @Override
        protected void applyTransformation(float interpolatedTime, Transformation t){
            super.applyTransformation(interpolatedTime, t);
            //更新佔比
            currentPercent = interpolatedTime * currentProgress / maxProgress;
            //重新繪製
            invalidate();

        }
    }

    /**
     * 設定進度條數值
     * @param currentProgress 當前進度
     * @param time 動畫持續時間
     */
    public void setProgress(float currentProgress,int time){
         this.currentProgress = currentProgress;
         //從0開始變化
         currentPercent = 0;
         //設定動畫時間
         waveProgressAnimat.setDuration(time);
         //當前檢視開啟動畫
         this.startAnimation(waveProgressAnimat);
    }

複製程式碼

最後在Activity呼叫一些程式碼:

        //進度為50 時間是2500毫秒
        circleWaveProgressView.setProgress(50,2500);
複製程式碼

最終效果如下圖:

波浪直線上升效果

繪製波浪左右平移

上面實現了波浪直線上升的動畫,下面實現波浪平移的動畫,新增左移的效果,這裡想到前面也實現了平移的效果,但是下面實現方式和上面有點出入,簡單來講就是移動p0座標,但是如果移動p0座標會出現波浪不鋪滿整個View的情況,這裡運用到一種很常見的迴圈處理辦法。在飛機大戰的背景滾動圖,是兩張背景圖拼接起來,當飛機從第一個背景圖片最底端出發,向上移動了第一個背景圖片高度的距離時,將角色重新放回到第一個背景圖片的最底端,這樣就能實現背景圖片迴圈的效果。也就是一開始繪製兩端p0-p1,然後隨著進度變化,p0會左移,一開始不在View中的波浪會從右邊往左邊移動出現,當滑動最大距離時,又重新繪製最開始狀態,這樣就達到迴圈了。還是先上分析圖:

水平位移分析圖
View的初始狀態是藍色區域,然後經過動畫位移慢慢變成紅色區域,程式碼實現如下:

    //波浪平移距離
    private float moveDistance = 0;
    
     /**
     * 繪製波浪線
     *
     * @return
     */
    private Path paintWavePath(){
        //要先清掉路線
        wavePath.reset();
        //起始點移至(0,waveHeight) p0 -p1 的高度隨著進度的變化而變化
        wavePath.moveTo(-moveDistance,(1 - currentPercent) * waveActualSize);
        //最多能繪製多少個波浪
        //其實也可以用 i < getWidth() ;i+=waveLength來判斷 這個沒那麼完美
        //繪製p0 - p1 繪製波浪線 這裡有一段是超出View的,在View右邊距的右邊 所以是* 2,為了水平位移
        for(int i = 0; i < waveNumber * 2 ; i ++){
             wavePath.rQuadTo(waveLength / 2,waveHeight,waveLength,0);
             wavePath.rQuadTo(waveLength / 2,-waveHeight,waveLength,0);
        }
        //連線p1 - p2
        wavePath.lineTo(waveActualSize,waveActualSize);
        //連線p2 - p3
        wavePath.lineTo(0,waveActualSize);
        //連線p3 - p0 p3-p0d的高度隨著進度變化而變化
        wavePath.lineTo(0,(1 - currentPercent) * waveActualSize);
        //封閉起來填充
        wavePath.close();
        return wavePath;
    }
    
    
     /**
     * 設定進度條數值
     * @param currentProgress 當前進度
     * @param time 動畫持續時間
     */
    public void setProgress(final float currentProgress, int time){
         this.currentProgress = currentProgress;
         //從0開始變化
         currentPercent = 0;
         //設定動畫時間
         waveProgressAnimat.setDuration(time);
         //設定迴圈播放
         waveProgressAnimat.setRepeatCount(Animation.INFINITE);
         //讓動畫勻速播放,避免出現波浪平移停頓的現象
         waveProgressAnimat.setInterpolator(new LinearInterpolator());
         waveProgressAnimat.setAnimationListener(new Animation.AnimationListener() {
             @Override
             public void onAnimationStart(Animation animation) {

             }

             @Override
             public void onAnimationEnd(Animation animation) {

             }

             @Override
             public void onAnimationRepeat(Animation animation) {
                 //波浪到達最高處後平移的速度改變,給動畫設定監聽即可,當動畫結束後以7000毫秒的時間執行,變慢了
                 if(currentPercent == currentProgress /maxProgress){
                     waveProgressAnimat.setDuration(7000);
                 }
             }
         });
         //當前檢視開啟動畫
         this.startAnimation(waveProgressAnimat);
    }
     //新建一個動畫類
    public class WaveProgressAnimat extends Animation{


        //在繪製動畫的過程中會反覆的呼叫applyTransformation函式,
        // 每次呼叫引數interpolatedTime值都會變化,該引數從0漸 變為1,當該引數為1時表明動畫結束
        @Override
        protected void applyTransformation(float interpolatedTime, Transformation t){
            super.applyTransformation(interpolatedTime, t);
            //波浪高度達到最高就不用迴圈,只需要平移
            if(currentPercent < currentProgress / maxProgress){
                currentPercent = interpolatedTime * currentProgress / maxProgress;
            }
            //左移的距離根據動畫進度而改變
            moveDistance = interpolatedTime * waveNumber * waveLength * 2;
            //重新繪製
            invalidate();

        }
    }
    
複製程式碼

最後的效果如下圖:

波浪上下左右移動效果圖

繪製圓形外框背景

這裡要用到PorterDuffXfermode的知識,其實也不難,先上PorterDuff.Mode各種模式的效果圖:

影象合成示意圖
這張圖看起來很正常,但這張圖其實給開發者造成很大的誤區,看下面這篇博文並且自己動手實踐一下android PorterDuffXferMode真正的效果測試集合(對比官方demo)。 下面用到了PorterDuff.Mode.SRC_IN,因為先繪製圓形背景,再繪製波浪線,而PorterDuff.Mode.SRC_IN模式在兩者相交的地方繪製源影象,並且繪製的效果會受到目標影象對應地方透明度的影響,看上圖就知道了,程式碼如下:

    //圓形背景畫筆
    private Paint circlePaint;
    //bitmap
    private Bitmap circleBitmap;
    //bitmap畫布
    private Canvas bitmapCanvas;
    
      /**
     * 初始化一些畫筆路徑配置
     * @param context
     */
    private void  init(Context context){
        //.......
        //繪製圓形背景開始
        wavePaint = new Paint();
        //設定畫筆為取交集模式
        wavePaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));
        //圓形背景初始化
        circlePaint = new Paint();
        //顏色
        circlePaint.setColor(Color.GRAY);
        //設定抗鋸齒
        circlePaint.setAntiAlias(true);
        
    }
    
     @Override
    protected void onDraw(Canvas canvas){
        super.onDraw(canvas);

        Log.d("ssd",getWidth()+"");
        //這裡用到了快取 根據引數建立新點陣圖
        circleBitmap = Bitmap.createBitmap(waveActualSize, waveActualSize, Bitmap.Config.ARGB_8888);
        //以該bitmap為低建立一塊畫布
        bitmapCanvas = new Canvas(circleBitmap);
        //繪製圓形 圓心 直徑都是很簡單得出
        bitmapCanvas.drawCircle(waveActualSize/2, waveActualSize/2, waveActualSize/2, circlePaint);
        //繪製波浪形
        bitmapCanvas.drawPath(paintWavePath(),wavePaint);
        //裁剪圖片
        canvas.drawBitmap(circleBitmap, 0, 0, null);
        //繪製波浪線
      //  canvas.drawPath(paintWavePath(),wavePaint);

    }

複製程式碼

實際效果如下圖:

最初版一
簡單的進度條終於出來了~下面繼續完善,到這裡你可能發現,顏色,大小什麼的都在類中定死了,但實際中有很多屬性都要在佈局檔案中設定的,同一個View在不同場景下狀態可能是不一樣的,所以為了提高擴充套件性,在res\vaules檔案下新增attrs.xml檔案,給CircleWaveProgressView新增自定義屬性,如下:


    <!--這裡的名字要和自定義的View名稱一樣,不然在xml佈局中無法引用-->
    <declare-styleable name="CircleWaveProgressView">
        <!--波浪的顏色-->
        <attr name="wave_color" format="color"></attr>
        <!--圓形背景顏色-->
        <attr name="circlebg_color" format="color"></attr>
        <!--波浪長度-->
        <attr name="wave_length" format="dimension"></attr>
        <!--波浪高度-->
        <attr name="wave_height" format="dimension"></attr>
        <!--當前進度-->
        <attr name="currentProgress" format="float"></attr>
        <!--最大進度-->
        <attr name="maxProgress" format="float"></attr>
    </declare-styleable>

複製程式碼

在自定義View為屬性值賦值:

    //波浪顏色
    private int wave_color;
    //圓形背景進度框顏色
    private int circle_bgcolor;
    
    
    public CircleWaveProgressView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        //獲取attrs檔案下配置屬性
        TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.CircleWaveProgressView);
        //獲取波浪寬度 第二個引數,如果xml設定這個屬性,則會取設定的預設值 也就是說xml沒有指定wave_length這個屬性,就會取Density.dip2px(context,25)
        waveLength = typedArray.getDimension(R.styleable.CircleWaveProgressView_wave_length,Density.dip2px(context,25));
        //獲取波浪高度
        waveHeight = typedArray.getDimension(R.styleable.CircleWaveProgressView_wave_height,Density.dip2px(context,15));
        //獲取波浪顏色
        wave_color = typedArray.getColor(R.styleable.CircleWaveProgressView_wave_color,Color.parseColor("#ff7c9e"));
        //圓形背景顏色
        circle_bgcolor = typedArray.getColor(R.styleable.CircleWaveProgressView_circlebg_color,Color.GRAY);
        //當前進度
        currentProgress = typedArray.getFloat(R.styleable.CircleWaveProgressView_currentProgress,50);
        //最大進度
        maxProgress = typedArray.getFloat(R.styleable.CircleWaveProgressView_maxProgress,100);
        //記得把TypedArray回收
        //程式在執行時維護了一個 TypedArray的池,程式呼叫時,會向該池中請求一個例項,用完之後,呼叫 recycle() 方法來釋放該例項,從而使其可被其他模組複用。
        //那為什麼要使用這種模式呢?答案也很簡單,TypedArray的使用場景之一,就是上述的自定義View,會隨著 Activity的每一次Create而Create,
        //因此,需要系統頻繁的建立array,對記憶體和效能是一個不小的開銷,如果不使用池模式,每次都讓GC來回收,很可能就會造成OutOfMemory。
        //這就是使用池+單例模式的原因,這也就是為什麼官方文件一再的強調:使用完之後一定 recycle,recycle,recycle
        typedArray.recycle();
        init(context);
    }
    
    /**
     * 初始化一些畫筆路徑配置
     * @param context
     */
    private void  init(Context context){
        //設定自定義View的寬高
        waveDefaultSize = Density.dip2px(context,250);
        //設定自定義View的最大寬高
        waveMaxHeight = Density.dip2px(context,300);

        wavePath = new Path();
        wavePaint = new Paint();
        //設定畫筆為取交集模式
        wavePaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));
        //圓形背景初始化
        circlePaint = new Paint();
        //設定圓形背景顏色
        circlePaint.setColor(circle_bgcolor);
        //設定抗鋸齒
        circlePaint.setAntiAlias(true);
        //設定波浪顏色
        wavePaint.setColor(wave_color);
        //設定抗鋸齒
        wavePaint.setAntiAlias(true);
        //佔比一開始設定為0
        currentPercent = 0;
        //進度條進度開始設定為0
        currentProgress = 0;
        //進度條的最大值設定為100
        maxProgress = 100;
        //動畫例項化
        waveProgressAnimat = new WaveProgressAnimat();


複製程式碼

下面就可以在佈局檔案自定義設定波浪顏色,高度,寬度以及圓形背景顏色:

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <com.android.quard.CircleWaveProgressView
        android:id="@+id/circle_progress"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:wave_color="@color/colorPrimaryDark"
        app:circlebg_color="@android:color/black"
        />

</android.support.constraint.ConstraintLayout>
複製程式碼

效果圖就不貼出來了。

繪製文字進度效果

下面要實現文字顯示進度,進度條肯定缺不了具體數值的顯示,最簡單就是直接在View中實現繪製文字的操作,這種是很簡單的,我之前實現自定義View都是將邏輯放在裡面,這樣就顯得View很臃腫和擴充套件性不高,因為你想,假如我現在要改變字型位置和樣式,那就需要在這個View去改去大動干戈。假如這個View能開放出處理文字介面的話,也就是後面修改文字樣式只通過這個介面就可以了,這樣就實現了文字和進度條這個View的解耦。

    //進度顯示 TextView
    private TextView tv_progress;
    //進度條顯示值監聽介面
    private UpdateTextListener updateTextListener;
    
        //新建一個動畫類
    public class WaveProgressAnimat extends Animation{


        //在繪製動畫的過程中會反覆的呼叫applyTransformation函式,
        // 每次呼叫引數interpolatedTime值都會變化,該引數從0漸 變為1,當該引數為1時表明動畫結束
        @Override
        protected void applyTransformation(float interpolatedTime, Transformation t){
            super.applyTransformation(interpolatedTime, t);
            //波浪高度達到最高就不用迴圈,只需要平移
            if(currentPercent < currentProgress / maxProgress){
                currentPercent = interpolatedTime * currentProgress / maxProgress;
                //這裡直接根據進度值顯示
                tv_progress.setText(updateTextListener.updateText(interpolatedTime,currentProgress,maxProgress));
            }
            //左邊的距離
            moveDistance = interpolatedTime * waveNumber * waveLength * 2;
            //重新繪製
            invalidate();

        }
    }
    
    //定義數值監聽
    public interface UpdateTextListener{
        /**
         * 提供介面 給外部修改數值樣式 等
         * @param interpolatedTime 這個值是動畫的 從0變成1
         * @param currentProgress 進度條的數值
         * @param maxProgress 進度條的最大數值
         * @return
         */
        String updateText(float interpolatedTime,float currentProgress,float maxProgress);
    }
    //設定監聽 
    public void setUpdateTextListener(UpdateTextListener updateTextListener){
        this.updateTextListener = updateTextListener;

    }

    /**
     *
     * 設定顯示內容
     * @param tv_progress 內容 數值什麼都可以
     *
     */
    public void setTextViewVaule(TextView tv_progress){
        this.tv_progress = tv_progress;

    }
複製程式碼

然後在Activity檔案實現CircleWaveProgressView.UpdateTextListener介面,進行邏輯處理:

public class MainActivity extends AppCompatActivity {

    private CircleWaveProgressView circleWaveProgressView;
    private TextView tv_value;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        //TextView控制元件
        tv_value = findViewById(R.id.tv_value);
        //進度條控制元件
        circleWaveProgressView = findViewById(R.id.circle_progress);
        //將TextView設定進度條裡
        circleWaveProgressView.setTextViewVaule(tv_value);
        //設定字型數值顯示監聽
        circleWaveProgressView.setUpdateTextListener(new CircleWaveProgressView.UpdateTextListener() {
            @Override
            public String updateText(float interpolatedTime, float currentProgress, float maxProgress) {
                //取一位整數和並且保留兩位小數
                DecimalFormat decimalFormat=new DecimalFormat("0.00");
                String text_value = decimalFormat.format(interpolatedTime * currentProgress / maxProgress * 100)+"%";
                //最終把格式好的內容(數值帶進進度條)
                return text_value ;
            }
        });
        //設定進度和動畫時間
        circleWaveProgressView.setProgress(50,2500);
    }
}
複製程式碼

佈局檔案增加一個TextView:

<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <com.android.quard.CircleWaveProgressView
        android:id="@+id/circle_progress"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        />

    <TextView
        android:id="@+id/tv_value"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        android:textColor="#ffffff"
        android:textSize="24dp"
        />


</android.support.constraint.ConstraintLayout>
複製程式碼

最終效果如下圖:

帶進度顯示的效果

繪製雙波浪效果

要實現第二層波浪平移方向和第一層波浪平移方向相反,要改一下繪製順序,。下圖:

第二層波浪分析圖
要從p1開始繪製,因為第二層是要右移,所以右邊要繪製多一次波浪,像繪製第一層波浪一樣,只不過這次是左邊,程式碼如下:

    //是否繪製雙波浪線
    private boolean isCanvasSecond_Wave;
    //第二層波浪的顏色
    private int second_WaveColor;
    //第二層波浪的畫筆
    private Paint secondWavePaint;
複製程式碼

attrs檔案增加第二層波浪的顏色:

  <!--這裡的名字要和自定義的View名稱一樣,不然在xml佈局中無法引用-->
    <declare-styleable name="CircleWaveProgressView">
        <!--波浪的顏色-->
        <attr name="wave_color" format="color"></attr>
        <!--圓形背景顏色-->
        <attr name="circlebg_color" format="color"></attr>
        <!--波浪長度-->
        <attr name="wave_length" format="dimension"></attr>
        <!--波浪高度-->
        <attr name="wave_height" format="dimension"></attr>
        <!--當前進度-->
        <attr name="currentProgress" format="float"></attr>
        <!--最大進度-->
        <attr name="maxProgress" format="float"></attr>
        <!--第二層波浪的顏色-->
        <attr name="second_color" format="color"></attr>
    </declare-styleable>
複製程式碼

類檔案:

        //第二層波浪的顏色
        second_WaveColor = typedArray.getColor(R.styleable.CircleWaveProgressView_second_color,Color.RED);
複製程式碼

init方法增加:

 //初始化第二層波浪畫筆
        secondWavePaint = new Paint();
        secondWavePaint.setColor(second_WaveColor);
        secondWavePaint.setAntiAlias(true);
        //要覆蓋在第一層波浪上,所以選SRC_ATOP模式,第二層波浪完全顯示,並且第一層非交集部分顯示。這個模式看上面的影象合成圖文章就可以瞭解
        secondWavePaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_ATOP));
        //初始狀態不繪製第二層波浪
        isCanvasSecond_Wave = false;
複製程式碼

onDraw方法增加:

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

        Log.d("ssd",getWidth()+"");
        //這裡用到了快取 根據引數建立新點陣圖
        circleBitmap = Bitmap.createBitmap(waveActualSize, waveActualSize, Bitmap.Config.ARGB_8888);
        //以該bitmap為低建立一塊畫布
        bitmapCanvas = new Canvas(circleBitmap);
        //繪製圓形 半徑設小了一點,就是為了能讓波浪填充完整個圓形背景
        bitmapCanvas.drawCircle(waveActualSize/2, waveActualSize/2, waveActualSize/2 - Density.dip2px(getContext(),8), circlePaint);
        //繪製波浪形
        bitmapCanvas.drawPath(paintWavePath(),wavePaint);
        //是否繪製第二層波浪
        if(isCanvasSecond_Wave){
            bitmapCanvas.drawPath(cavasSecondPath(),secondWavePaint);

        }
        //裁剪圖片
        canvas.drawBitmap(circleBitmap, 0, 0, null);
        //繪製波浪線
      //  canvas.drawPath(paintWavePath(),wavePaint);

    }
    
        //是否繪製第二層波浪
    public void isSetCanvasSecondWave(boolean isCanvasSecond_Wave){
        this.isCanvasSecond_Wave = isCanvasSecond_Wave;
    }

    /**
     * 繪製第二層波浪方法
     * @return
     */
    private Path cavasSecondPath(){
        float secondWaveHeight = waveHeight;
        wavePath.reset();
        //移動到右上方,也就是p1點
        wavePath.moveTo(waveActualSize + moveDistance, (1 - currentPercent) * waveActualSize);
        //p1 - p0
        for(int i = 0; i < waveNumber * 2 ; i ++){
            wavePath.rQuadTo(-waveLength / 2,secondWaveHeight,-waveLength,0);
            wavePath.rQuadTo(-waveLength / 2,-secondWaveHeight,-waveLength,0);
        }
        //p0-p3 p3-p0d的高度隨著進度變化而變化
        wavePath.lineTo(0, waveActualSize);
        //連線p3 - p2
        wavePath.lineTo(waveActualSize,waveActualSize);
        //連線p2 - p1
        wavePath.lineTo(waveActualSize,(1 - currentPercent) * waveActualSize);
        //封閉起來填充
        wavePath.close();
        return wavePath;

    }
複製程式碼

最後在Activty檔案設定:

        //是否繪製第二層波浪
        circleWaveProgressView.isSetCanvasSecondWave(true);
複製程式碼

最終效果如下圖:

最終效果圖

總結

經過貝塞爾公式的推導和小例子實現,有了更深刻的印象。有很多東西看起來並不是那麼觸達,就好像當自己拿到一個開發需求時,技術評估發現會用到自己沒有之前沒有用到的技術,這時候就要多去參照別人實現的思路和方法,或者厚著臉皮問技術牛的人,學到了就是自己的,多付出就努力變得容易。

例子原始碼

相關文章