每次聽到某大牛談論自定義View,頓時敬佩之心,如滔滔江水連綿不絕,心想我什麼時候能有如此境界,好了,心動不如行動,于是我開始了自定義View之路,雖然過程有坎坷,但是結果我還是挺滿意的。我知道大牛還遙不可及,但是我已使出洪荒之力。此篇部落格記錄本人初入自定義View之路。
既然是初出茅廬,自然是按部就班的進行,先來一張效果圖
![](https://img.laitimes.com/img/9ZDMuAjOiMmIsIjOiQnIsISO2kTOxATMyEzNxgDM2EDMy8CX0Vmbu4GZzNmLn9Gbi1yZtl2Lc9CX6MHc0RHaiojIsJye.jpg)
本文章所寫項目代碼的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>
布局檔案配置相關内容
在布局檔案要配置我們自定義的屬性,首先要自定義命名空間,
如上圖,如果在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類,先看構造方法,我們寫成構造方法最終調用三個參數的構造方法,擷取自定義屬性的值及初始化工作就在三個參數構造方法中進行。下面我先先來繪制一個圓,文字畫在圓心試試手,效果如圖
當然繪制這個圖形,首先擷取我們自定義屬性值,可通過下面擷取屬性值
注意通過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的靜态内部類,通過注釋我們就知道了每個變量的含義,為了更生動的了解這幾個變量含義,我們通過下面的一張圖來分别解釋每個變量的含義
- 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。
通過上面的分析,我們就得出了将文本繪制中心的代碼如下
//測量文字的寬度
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的詳細解釋
通過上面的分析,繪制此圖形的完整代碼為 點選檢視
控件更新
上面我們已經實作了圓形和文本的繪制,那麼接下來,我們先開始實作中心新進度的更新繪制。先看效果圖
通過效果圖,我們看到實作此效果就是不斷的更新進度值,然後重繪,,那麼我們隻需開啟一個線程實作更新進度值,為了更好的控制我們再加點選事件,當單機時開始增大進度,輕按兩下時暫停進度,并彈出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,到這裡我們就完成的中心進度值得更新,接下來就開始繪制裡面的波浪形狀,效果圖如下
實作水波浪效果
水波紋效果是通過二階貝塞爾曲線實作的,先簡單看下什麼是貝塞爾曲線
在數學的數值分析領域中,貝塞爾曲線(英語: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方向的“長度有多長”。
曲線的參數形式為:
當然貝塞爾曲線是一個很複雜的東西,他可以延伸N階貝塞爾曲線,如果想要真正搞明白,想自定義比較複雜或者比較酷炫的動畫,那高等數學知識必須要搞明白,很多時候,我們隻需要了解二次貝塞爾曲線就可以了,或者說,即使貝塞爾曲線不是那麼熟悉,也不用怕,android API 封裝了常用的貝塞爾曲線,我們隻需要傳入坐标就可以實作很多動畫。自定義View實作圓形水波進度條
首先我們需要初始化貝塞爾曲線區域的畫筆設定。其中重要的一點就是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 *
如圖,我們就可以先将path.lineTo将每個點連起來,可以先從(width,y)繪制,那麼需要調用path.moveTo(width, y);方法将操作點移動到該坐标上,接下下就開始依次連接配接其餘三個點(width,height),(0,height),(0,y)。由于我們之前畫筆設定的是取交集(progressPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN))),是以此時會繪制與圓相交的部分,也就是圓内的部分。
下面就是繪制貝塞爾曲線
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,到這裡,自定義的水波形狀的進度條就完成了,再次上效果圖
(注:此水波左右移動是後來加的效果,具體實作點選代碼檢視)
![](https://img.laitimes.com/img/9ZDMuAjOiMmIsIjOiQnIsISO2kTOxATMyEzNxgDM2EDMy8CX0Vmbu4GZzNmLn9Gbi1yZtl2Lc9CX6MHc0RHaiojIsJye.jpg)
由于本人目前水準有限,文字若有不足的地方,歡迎指正,謝謝。