天天看點

自定義View實作圓形水波進度條

每次聽到某大牛談論自定義View,頓時敬佩之心,如滔滔江水連綿不絕,心想我什麼時候能有如此境界,好了,心動不如行動,于是我開始了自定義View之路,雖然過程有坎坷,但是結果我還是挺滿意的。我知道大牛還遙不可及,但是我已使出洪荒之力。此篇部落格記錄本人初入自定義View之路。

既然是初出茅廬,自然是按部就班的進行,先來一張效果圖

自定義View實作圓形水波進度條

本文章所寫項目代碼的GitHub連結

自定義屬性

自定義屬性,就是在資源檔案夾下values目錄中建立一個attrs.xml檔案,

檔案結構如下所示,atrr标簽就是我們要自定義的一些屬性,name就是自定義屬性的名字,那麼format是做什麼的呢?

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="">
        <attr name="centerText" format=""></attr>
        <attr name=" ">
            <enum name=""  value=" "></enum>
            <enum name="" value=" "></enum>
        </attr>
    </declare-styleable>
</resources>
           

format是屬性對應的值的類型,有十個值

  • enm 枚舉類型,例 android:orientation=”vertical” 此值有horizontal,和 vertical
  • dimension 尺寸值
  • color 顔色值,例 android:textColor = “#00FF00”
  • boolean 布爾值,true or false
  • flag 位或運算
  • float 浮點型
  • fraction 百分數,
  • reference 參考某一資源ID,例 android:background = “@drawable/ic_launcher”
  • string 字元串類型
  • integer 整型值

知道了這些值得含義,就可以自定義我們自己的屬性了,對于這個進度條,我們可以自定義圓的半徑,顔色,和圓中心文本的大小,顔色,文本,最後attrs.xml檔案為

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="CustomBallView">
        <attr name="centerText" format="string"></attr>
        <attr name="centerTextSize" format="dimension"></attr>
        <attr name="centerTextColor" format="color"></attr>
        <attr name="ballColor" format="color"></attr>
        <attr name="ballRadius" format="dimension"></attr>
    </declare-styleable>
</resources>
           

布局檔案配置相關内容

在布局檔案要配置我們自定義的屬性,首先要自定義命名空間,

自定義View實作圓形水波進度條

如上圖,如果在as中命名空間寫成http://schemas.android.com/apk/res/包名 此時as會報錯,這是gradle造成的,在eclipse中如果自定義的屬性 是不能用res-auto的 必須得替換成你自定義view所屬的包名,如果你在恰好使用的自定義屬性被做成了lib 那就隻能使用res-auto了,而在android-studio裡,無論你是自己寫自定義view 還是引用的lib裡的自定義的view 都隻能使用res-auto這個寫法。以前那個包名的寫法 在android-studio裡是被廢棄無法使用的

是以配置後的布局檔案如下

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:customBallView="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context="com.example.xh.customball.MainActivity"
    tools:showIn="@layout/activity_main">

    <TextView
        android:id="@+id/textView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:visibility="gone"
        android:text="Hello World!" />

    <com.example.xh.customball.CustomBall
        android:background="@color/colorPrimary"
        android:layout_centerInParent="true"
        android:layout_margin="10dp"
        customBallView:centerText="30%"
        customBallView:centerTextSize="28dp"
        customBallView:centerTextColor="#000000"
        customBallView:ballColor="@color/colorAccent"
        customBallView:ballRadius="30dp"
        android:layout_width="260dp"
        android:layout_height="260dp">
    </com.example.xh.customball.CustomBall>
</LinearLayout>
           

自定義控件

有了上邊的操作,接下來就開始到了真正自定義控件的時候了,建立一個CustomBall類繼承View類,先看構造方法,我們寫成構造方法最終調用三個參數的構造方法,擷取自定義屬性的值及初始化工作就在三個參數構造方法中進行。下面我先先來繪制一個圓,文字畫在圓心試試手,效果如圖

自定義View實作圓形水波進度條

當然繪制這個圖形,首先擷取我們自定義屬性值,可通過下面擷取屬性值

注意通過TypedArray 擷取屬性值後要執行typedArray.recycle();回收記憶體,防止記憶體洩漏。

/**
* 擷取自定義屬性
*/
TypedArray typedArray=context.obtainStyledAttributes(attrs,R.styleable.customBallView);
        centerText=typedArray.getString(R.styleable.customBallView_centerText);
        Log.e("TAG","centerText"+centerText);
        centerTextSize=typedArray.getDimension(R.styleable.customBallView_centerTextSize,f);
        centerTextColor=typedArray.getColor(R.styleable.customBallView_centerTextColor,);
        ballColor=typedArray.getColor(R.styleable.customBallView_ballColor,);
        radius=typedArray.getDimension(R.styleable.customBallView_ballRadius,f);
        typedArray.recycle();
           

初始化畫筆

/**
     * 初始化畫筆
     */
    private void initPaint() {
        roundPaint = new Paint();
        roundPaint.setColor(ballColor);
        roundPaint.setAntiAlias(true);//抗鋸齒
        fontPaint = new Paint();
        fontPaint.setTextSize(centerTextSize);
        fontPaint.setColor(centerTextColor);
        fontPaint.setAntiAlias(true);
        fontPaint.setFakeBoldText(true);//粗體

    }
           

接下來我們先畫一個圓,先通過下面方法擷取空間本身的寬和高,然後調用canvas.drawCircle(width/2, height/2, radius, roundPaint);畫圓,在原點設定為控件中心位置,即點(width/2, height/2),半徑為radius,畫筆roundPaint,接下來繪制文字,将位子繪制在圓的中心。

width = getWidth() ;
        height = getHeight();
           

如果我們通過canvas.drawText(centerText, width/2, height/2, fontPaint);繪制文字的話,發現文字并不是在中心位置,那麼我們可以做一下調整,canvas.drawText(centerText, width/2, height/2, fontPaint);先通過float textWidth = fontPaint.measureText(centerText);擷取文字的寬度,canvas.drawText(centerText, width/2-textWidth /2, height/2, fontPaint);此時文字依然不在中心,那麼此時我們研究一下文字到底是怎麼繪制的,為什麼坐标試試中心了,繪制出來的效果依然有偏差呢。

要關注文字繪制的話,FontMetrics這個類是必須要知道的因為它的作用是測量文字,它裡面呢就定義了top,ascent,descent,bottom,leading五個成員變量其他什麼也沒有。先看源碼

public static class FontMetrics {
        /**
         * The maximum distance above the baseline for the tallest glyph in
         * the font at a given text size.
         */
        public float   top;
        /**
         * The recommended distance above the baseline for singled spaced text.
         */
        public float   ascent;
        /**
         * The recommended distance below the baseline for singled spaced text.
         */
        public float   descent;
        /**
         * The maximum distance below the baseline for the lowest glyph in
         * the font at a given text size.
         */
        public float   bottom;
        /**
         * The recommended additional space to add between lines of text.
         */
        public float   leading;
    }
           

這個類是Paint的靜态内部類,通過注釋我們就知道了每個變量的含義,為了更生動的了解這幾個變量含義,我們通過下面的一張圖來分别解釋每個變量的含義

自定義View實作圓形水波進度條
  • Baseline(基線) 在Android中,文字的繪制都是從Baseline處開始的
  • ascent(上坡度)Baseline往上至文字“最高處”的距離我們稱之為ascent,
  • descent(下坡度)Baseline往下至文字“最低處”的距離我們稱之為descent(下坡度)
  • leading(行間距)表示上一行文字的descent到該行文字的ascent之間的距離
  • top 對于ascent上面還有一部分内邊距,内邊距加上ascent即為top值
  • bottom descent和内邊距的加上descent距離

    值得注意的一點,Baseline上方的值為負,下方的值為正如下圖文字30%的ascent,descent,top,bottom。

自定義View實作圓形水波進度條

通過上面的分析,我們就得出了将文本繪制中心的代碼如下

//測量文字的寬度
float textWidth = fontPaint.measureText(centerText);
        float x = width /  - textWidth / ;
        Paint.FontMetrics fontMetrics = fontPaint.getFontMetrics();
        float dy = -(fontMetrics.descent + fontMetrics.ascent) / ;
        float y = height /   + dy;
        canvas.drawText(centerText, x, y, fontPaint);
           

至此這個簡單自定義的View基本實作,此時我改了布局配置檔案為寬高

android:layout_width="wrap_content"
        android:layout_height="wrap_content"
           

或者

android:layout_width="match_parent"
        android:layout_height="match_parent"
           

Oh my God,為什麼效果是一樣的啊,此時再回到自定義的類,我們發現我們沒有實作onMeasure裡面測量的代碼,接下來讓我們實作onMeasure操作,如下

@Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        //測量模式
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        //測量規格大小
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);

        int width;
        int height;
        if (widthMode == MeasureSpec.EXACTLY) {
            width=widthSize;
        } else if (widthMode == MeasureSpec.AT_MOST) {
            width=(int)Math.min(widthSize,radius*);
        } else {

            width=windowWidth;
        }
        if (heightMode == MeasureSpec.EXACTLY) {
            height=heightSize;
        } else if (heightMode == MeasureSpec.AT_MOST) {
            height=(int)Math.min(heightSize,radius*);
        } else {
            height=windowHeight;
        }
        setMeasuredDimension(width,height);
    }
           

測量主要依靠MeasureSpec,MeasureSpec(測量規格)是一個32位的int資料.其中高2位代表SpecMode即某種測量模式,低32位為SpecSize代表在該模式下的規格大小,測量模式有三種

  • EXACTLY 确切的,在布局檔案中設定的寬高是固定的,此時測量大小就是我們設定的寬高
  • AT_MOST 至多,不能超出
  • UNSPECIFIED 未指定

MeasureSpec的詳細解釋

通過上面的分析,繪制此圖形的完整代碼為 點選檢視

控件更新

上面我們已經實作了圓形和文本的繪制,那麼接下來,我們先開始實作中心新進度的更新繪制。先看效果圖

自定義View實作圓形水波進度條

通過效果圖,我們看到實作此效果就是不斷的更新進度值,然後重繪,,那麼我們隻需開啟一個線程實作更新進度值,為了更好的控制我們再加點選事件,當單機時開始增大進度,輕按兩下時暫停進度,并彈出Snackbar,其中有一個重置按鈕,點選重置時将進度設定為0,重繪界面。

  • 響應點選事件

    因為要實作輕按兩下事件,我們可以直接用GestureDetector(手勢檢測),通過這個類我們可以識别很多的手勢,主要是通過他的onTouchEvent(event)方法完成了不同手勢的識别GestureDetector裡有一個内部類 SimpleOnGestureListener。SimpleOnGestureListener類是GestureDetector提供給我們的一個更友善的響應不同手勢的類,這個類實作了上述兩個接口(OnGestureListener, OnDoubleTapListener,但是所有的方法體都是空的),該類是static class,也就是說它實際上是一個外部類。程式員可以在外部繼承這個類,重寫裡面的手勢處理方法

public static class SimpleOnGestureListener implements OnGestureListener, OnDoubleTapListener,
            OnContextClickListener {
//單擊擡起
        public boolean onSingleTapUp(MotionEvent e) {
            return false;
        }
//長按
        public void onLongPress(MotionEvent e) {
        }
//滾動
        public boolean onScroll(MotionEvent e1, MotionEvent e2,
                float distanceX, float distanceY) {
            return false;
        }
//快速滑動
        public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX,
                float velocityY) {
            return false;
        }
//
        public void onShowPress(MotionEvent e) {
        }

        public boolean onDown(MotionEvent e) {
            return false;
        }

        public boolean onDoubleTap(MotionEvent e) {
            return false;
        }

        public boolean onDoubleTapEvent(MotionEvent e) {
            return false;
        }

        public boolean onSingleTapConfirmed(MotionEvent e) {
            return false;
        }

        public boolean onContextClick(MotionEvent e) {
            return false;
        }
    }
           

下面是我們自定繼承SimpleOnGestureListener,由于我們隻要響應單擊和輕按兩下事件,那麼我們隻需要重寫onDoubleTap輕按兩下(),onSingleTapConfirmed(單擊)方法即可,

public class MyGestureDetector extends GestureDetector.SimpleOnGestureListener {

        @Override
        public boolean onDoubleTap(MotionEvent e) {
            getHandler().removeCallbacks(singleTapThread);
            singleTapThread=null;
            Snackbar.make(CustomBall.this, "暫停進度,是否重置進度?", Snackbar.LENGTH_LONG).setAction("重置", new OnClickListener() {
                @Override
                public void onClick(View v) {
                    currentProgress=;
                    invalidate();
                }
            }).show();
            return super.onDoubleTap(e);
        }

        @Override
        public boolean onSingleTapConfirmed(MotionEvent e) {
            Snackbar.make(CustomBall.this, "單機了", Snackbar.LENGTH_LONG).setAction("Action", null).show();
            startProgressAnimation();
            return super.onSingleTapConfirmed(e);
        }
    }
           

當點選時Snackbar做個提醒單擊了View,然後調用startProgressAnimation()方法初始化一個線程,通過postDelayed将線程加入的消息隊列,延遲100ms執行,通過singleTapThread == null判斷條件,避免過多的建立對象

private void startProgressAnimation() {
        if (singleTapThread == null) {
            singleTapThread = new SingleTapThread();
            getHandler().postDelayed(singleTapThread, );
        }
    }
           

我們将SingleTapThread 實作Runnable接口,在run方法裡書寫我們的處理邏輯,其實很簡單,先判斷目前進度值是不是大于最大進度(100),如果小于最大的值,我們就将currentProgress(目前進度值)加1的操作,然後調用invalidate()方法重繪界面,之後還需要再次将線程加入消息隊列,依然延遲100ms執行。對于當如果目前進度已經加載到100%,此時我們将此線程從消息隊列移除。

private class SingleTapThread implements Runnable {
        @Override
        public void run() {
            if (currentProgress < maxProgress) {
                currentProgress++;
                invalidate();
                getHandler().postDelayed(singleTapThread, );

            } else {
                getHandler().removeCallbacks(singleTapThread);
            }
        }
    }
           

接下來還需要注冊事件,我們可以在onDraw()方法中通過GestureDetector的構造方法可以将自定義的MyGestureDetector對象傳遞進去,然後通setOnTouchListener設定監聽器,這樣GestureDetector能處理不同的手勢了

if (detector==null){
            detector = new GestureDetector(new MyGestureDetector());
            setOnTouchListener(new OnTouchListener() {
                @Override
                public boolean onTouch(View v, MotionEvent event) {
                    return detector.onTouchEvent(event);
                }
            });

        }
           

還有最重要的一點是,View預設是不可點選的,是以我們需要 setClickable(true)設定View可點選的,OK,到這裡我們就完成的中心進度值得更新,接下來就開始繪制裡面的波浪形狀,效果圖如下

自定義View實作圓形水波進度條

實作水波浪效果

水波紋效果是通過二階貝塞爾曲線實作的,先簡單看下什麼是貝塞爾曲線

在數學的數值分析領域中,貝塞爾曲線(英語:Bézier curve)是電腦圖形學中相當重要的參數曲線。更高次元的廣泛化貝塞爾曲線就稱作貝塞爾曲面,其中貝塞爾三角是一種特殊的執行個體。

貝塞爾曲線于1962年,由法國工程師皮埃爾·貝塞爾(Pierre Bézier)所廣泛發表,他運用貝塞爾曲線來為汽車的主體進行設計。貝塞爾曲線最初由Paul de Casteljau于1959年運用de Casteljau算法開發,以穩定數值的方法求出貝塞爾曲線 - - - - -維基百科

  • 線性貝塞爾曲線

    給定點P0、P1,線性貝塞爾曲線隻是一條兩點之間的直線。這條線由下式給出:

    自定義View實作圓形水波進度條
    繪制效果為
    自定義View實作圓形水波進度條
  • 二次方貝塞爾曲線

    二次方貝塞爾曲線的路徑由給定點P0、P1、P2的函數B(t)追蹤:

    自定義View實作圓形水波進度條
    自定義View實作圓形水波進度條
  • 三次方貝塞爾曲線

    P0、P1、P2、P3四個點在平面或在三維空間中定義了三次方貝塞爾曲線。曲線起始于P0走向P1,并從P2的方向來到P3。一般不會經過P1或P2;這兩個點隻是在那裡提供方向資訊。P0和P1之間的間距,決定了曲線在轉而趨進P2之前,走向P1方向的“長度有多長”。

    曲線的參數形式為:

    自定義View實作圓形水波進度條
    當然貝塞爾曲線是一個很複雜的東西,他可以延伸N階貝塞爾曲線,如果想要真正搞明白,想自定義比較複雜或者比較酷炫的動畫,那高等數學知識必須要搞明白,很多時候,我們隻需要了解二次貝塞爾曲線就可以了,或者說,即使貝塞爾曲線不是那麼熟悉,也不用怕,android API 封裝了常用的貝塞爾曲線,我們隻需要傳入坐标就可以實作很多動畫。

首先我們需要初始化貝塞爾曲線區域的畫筆設定。其中重要的一點就是setXfermode()方法,此方法可以設定與其他繪制圖形的交集,合集,補集等運算,在這個項目中,我們使用了交集(繪制貝塞爾曲線區域和圓區域的交集)

progressPaint = new Paint();
        progressPaint.setAntiAlias(true);
        progressPaint.setColor(progressColor);
        //取兩層繪制交集。顯示上層
        progressPaint.setXfermode(new                 PorterDuffXfermode(PorterDuff.Mode.SRC_IN));
           

初始化畫筆後,就開始繪制我們的圖形,先初始化一個

寬和高都為radius * 2的正方形畫布作為緩沖區畫布,我們可以先在緩沖區畫布繪制,繪制完成後一次再繪制到畫布上。

bitmap = Bitmap.createBitmap((int) radius * , (int) radius * , Bitmap.Config.ARGB_8888);

        bitmapCanvas = new Canvas(bitmap);
           

然後繪制圓心(width / 2, height / 2)半徑為radius的圓

水波從圓的最下方開始(進度為0),到最上方(進度最大值maxProgress)結束,那麼我們需要根據目前進度值動态計算水波的高度

float y = ( - (float) currentProgress / maxProgress) * radius * 
           
自定義View實作圓形水波進度條

如圖,我們就可以先将path.lineTo将每個點連起來,可以先從(width,y)繪制,那麼需要調用path.moveTo(width, y);方法将操作點移動到該坐标上,接下下就開始依次連接配接其餘三個點(width,height),(0,height),(0,y)。由于我們之前畫筆設定的是取交集(progressPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN))),是以此時會繪制與圓相交的部分,也就是圓内的部分。

自定義View實作圓形水波進度條

下面就是繪制貝塞爾曲線

path.rQuadTo(space, -d, space * , );
            path.rQuadTo(space, d, space * , );
           

第一個是繪制向下彎曲,第二個是繪制向上彎曲。為了從左到右都繪制曲線,我們根據圓的直徑計算一下,需要幾次才能平鋪,然後循環執行上面兩句,直到平鋪圓形區域,為了展示當進度增大時将波紋幅度降低的效果(直到進度為100%,幅度降為0)我們根據目前進度值動态計算了幅度值,計算方法如下

float d = ( - (float) currentProgress / maxProgress) *space;
           

由于我們需要以實心的方式繪制區域,那麼我們調用

path.close();将所畫區域封閉,也就是實心的效果。

path.close();
        bitmapCanvas.drawPath(path, progressPaint);
           

Ok,到這裡,自定義的水波形狀的進度條就完成了,再次上效果圖

(注:此水波左右移動是後來加的效果,具體實作點選代碼檢視)

自定義View實作圓形水波進度條

由于本人目前水準有限,文字若有不足的地方,歡迎指正,謝謝。

繼續閱讀