Android自定義View系列(一)——打造一個愛心進度條
寫作原因:Android進階過程中有一個繞不開的話題——自定義View。這一塊是安卓程序員更好地實現功能自主化必須邁出的一步。下面這個系列博主將通過實現幾個例子來認識安卓自定義View的方法。從自定義View到自定義ViewGroup,View事件處理再到View深入分析(這一章如果水平未到位可能今后再補充),其中會涉及一些小的知識,包括Canvas的使用、動畫等等。系列第一篇文章通過繪制一個心形進度條來學習自定義View的整體流程和簡單地貝塞爾曲線的用法。下面開始折磨鍵盤吧。
最終效果
先看看今天我們要實現的效果:
具體功能就是一個心形的進度條,跟普通的進度條相似,但是顯示進度的方式和整體的外觀實現了自定義化。這個進度條會根據進度不斷加深顏色,效果還不錯。通過這個例子讀者可以學會基本的自定義View的方法。
基本思路
我們需要新建一個attrs.xml來描述HeartProgressBar的屬性,一個HeartProgressBar.java繼承ProgressBar(直接繼承View也行),然后通過取出屬性,測量,繪制幾步來實現自定義View全過程。
具體實現
一、定義XML屬性文件
新建values/attrs.xml,在XML中聲明好各個屬性,注意一下format,詳細的用法參考官方文檔。然后在
標簽下引入屬性,具體見下面:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<attr name="UnReachedColor" format="color"/>
<attr name="ReachedColor" format="color"/>
<attr name="InnerTextColor" format="color"/>
<attr name="InnerTextSize" format="dimension"/>
<attr name="Progress" format="integer"/>
<declare-styleable name="HeartProgressBar">
<attr name="UnReachedColor"/>
<attr name="ReachedColor" />
<attr name="InnerTextColor" />
<attr name="InnerTextSize" />
<attr name="Progress"/>
</declare-styleable>
</resources>
二、獲取XML中的屬性
這一步我們使用obtainAttributes()方法來獲取開發者在布局中為我們的View設定的參數值。通過 TypedArray ta = getResources().obtainAttributes(attrs,R.styleable.HeartProgressBar); 獲得TypedArray對象,使用該對象的get系列方法來獲取參數值,如: unReachedColor = ta.getColor(R.styleable.HeartProgressBar_UnReachedColor,UNREACHEDCOLOR_DEFAULT); 。后面的UNREACHEDCOLOR_DEFAULT是默認參數值,是view創建者定義的,注意尺寸相關的需要進行單位轉換。這樣就取到了View的參數,這些參數是我們用來定義View的部分元素。
三、調用onMeasure()測量
這一步常常令許多人頭大甚至望而卻步,看了很多資料也理解不了。我在這里分享一下對于這一塊的理解,希望能夠幫助大家理解。首先得明白View的規格是受承載它的View或者ViewGroup影響的。這點很重要,因為這點才出現了三種測量模式:MeasureSpec.EXACTLY、MeasureSpec.UNSPECIFIED和MeasureSpec.AT_MOST。這三種測量模式分別對應三種情況:View有確定的寬高(包括match_parent和具體值兩種情況),此時使用EXACTLY,直接把MeasureSpec.getSize()返回就行;View沒有確定的寬高(即wrap_content),此時可能系統會使用MeasureSpec.UNSPECIFIED或者MeasureSpec.AT_MOST。在MeasureSpec.UNSPECIFIED中我們把盡量容納View的尺寸返回給父View去處理,而在MeasureSpec.AT_MOST中則由于父View對子View的限制需要比對父View的限制的最大情況和子View盡可能容納的尺寸,然后返回相對較小的值。看看本文的例子:
int usedHeight = getRealHeight(heightMeasureSpec);
int usedWidth = getRealWidth(widthMeasureSpec);
setMeasuredDimension(usedWidth,usedHeight);
這里將寬高測量后使用setMeasureDimension()返回給父View去處理。
以寬為例,代碼如下:
public int getRealWidth(int widthMeasureSpec) {
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthVal = MeasureSpec.getSize(widthMeasureSpec);
//取得父View或ViewGroup分配的默認為子View的大小和模式
paddingLeft = getPaddingLeft();
paddingRight = getPaddingRight();
//注意處理一下Padding,把Padding值也賦給想要設置的子View的大小
if(widthMode == MeasureSpec.EXACTLY){
return paddingLeft+paddingRight+widthVal;
//精確模式下返回具體值
}else if(widthMode == MeasureSpec.UNSPECIFIED){
return (int) (Math.abs(underPaint.ascent()-underPaint.descent()) + paddingLeft + paddingRight);
//未確定模式下返回盡可能容納View的尺寸
}else{
return (int) Math.min((Math.abs(underPaint.ascent()-underPaint.descent()) + paddingLeft + paddingRight),widthVal);
//AT_MOST下返回父View限制值和View盡可能大尺寸之間的最小值
}
}
使用MeasureSpec.getMode()和getSize()分別取得測量模式和大小,然后處理三種測量模式下的大小值,最后再使用setMeasureDimension()將值返回。對于三種模式下的處理見上面的注釋。自定義View中的測量是一塊難點,應該詳細閱讀并實踐。
四、重寫onSizeChanged()獲取最終View的寬高值
當父View真正為子View分配好空間后會回調這個方法,所以我們應該在里面取得最終的大小值。代碼如下:
realWidth = w;
realHeight = h;
五、重寫onDraw()方法繪制圖像
這一步是整個流程中的重點步驟,所有的繪制工作都是在這里進行的。一般繪制的時候都要考慮尺寸問題,我們使用的寬高尺寸是onSizeChanged里面取出的,使用該尺寸來繪制,注意一定要對Padding進行處理(博主寫完發現沒處理,現在要修改發現很浪費時間就沒做處理了。。。讀者可以自行處理作為練習)這里有個難點,由于心形的特殊性,我們使用貝塞爾曲線來繪制。關于貝塞爾曲線,在這里就不班門弄斧了,只是說一下這種曲線可以將許多復雜的曲線轉化成數學公式來描述,曲線由兩種類型的點決定:起末點決定曲線的大概位置,其他點決定曲線的形狀和彎曲程度。貝塞爾曲線有一階二階三階高階之分,具體見下面圖片。
安卓中支持二階和三階貝塞爾曲線,方法分別為quadTo()和cubicTo()兩個。本文使用cubicTo來實現。但是想想心形的形狀,如果真的要自己用數學的方法去確定那幾個點的位置,呵呵,我是辦不到了。那怎么辦?博主找到一個方法,在線生成貝塞爾曲線然后用ps標尺工具來確定點與寬高的比例關系……雖然還是很麻煩,但我還沒想到別的方法或者工具。( Canvas二次貝塞爾曲線操作實例 ,這是一個為h5服務的貝塞爾曲線生成工具,將就用下……)如果讀者有希望能分享一下,謝謝!
這是我得到的測量圖(就是在這里忘了考慮padding,改起來又很麻煩):
接下來就是利用這張圖和Canvas來作圖了,主要路徑關注路徑和顏色深淺表示進度的實現:
float pro = ((float)progress)/100.0f;
int nowColor = (int) argbEvaluator.evaluate(pro,unReachedColor,reachedColor);
underPaint.setColor(nowColor);
上面代碼實現了View的顏色隨著進度的變化而從某個顏色向另一個顏色變化的功能。ArgbEvaluator類挺實用的,可以實現微信底部滑動顏色變化的功能,這里也是利用它來實現的。
path.moveTo((float) (0.5*realWidth), (float) (0.17*realHeight));
path.cubicTo((float) (0.15*realWidth), (float) (-0.35*realHeight), (float) (-0.4*realWidth), (float) (0.45*realHeight), (float) (0.5*realWidth),realHeight);
path.moveTo((float) (0.5*realWidth),realHeight);
path.cubicTo((float) (realWidth+0.4*realWidth), (float) (0.45*realHeight),(float) (realWidth-0.15*realWidth), (float) (-0.35*realHeight),(float) (0.5*realWidth), (float) (0.17*realHeight));
path.close();
上述代碼是path路徑的繪制,繪制了一個心形的path,如果對于這兩個方法有疑問的可以查看API文檔。
由于我們的進度條跟隨進度發生變化,所以我們要重寫setProgress()方法,使用invalidate()來刷新onDraw()重繪實現變化。代碼如下:
@Override
public void setProgress(int progress) {
this.progress = progress;
invalidate();
}
總結
Android自定義View除了上面的測量繪制之外還有對點擊事件的處理一大塊,這里每個地方都需要花時間去理解和實踐才能搞懂,下篇博主會就事件處理和動畫一塊再次自定義一個View,如果覺得寫得好的希望繼續關注并喜歡我的簡書,也可以關注我的博客。
附錄:View具體代碼
public class HeartProgressBar extends ProgressBar {
private final static int UNREACHEDCOLOR_DEFAULT = 0xFF69B4;
private final static int REACHEDCOLOR_DEFAULT = 0xFF1493;
private final static int INNERTEXTCOLOR_DEFAULT = 0xDC143C;
private final static int INNERTEXTSIZE_DEFAULT = 10;
private static final int PROGRESS_DEFAULT = 0;
private int unReachedColor;
private int reachedColor;
private int innerTextColor;
private int innerTextSize;
private int progress;
private int realWidth;
private int realHeight;
private Paint underPaint;
private Paint textPaint;
private Path path;
private int paddingTop;
private int paddingBottom;
private int paddingLeft;
private int paddingRight;
private ArgbEvaluator argbEvaluator;
public HeartProgressBar(Context context) {
this(context,null);
}
public HeartProgressBar(Context context, AttributeSet attrs) {
super(context, attrs);
argbEvaluator = new ArgbEvaluator();
TypedArray ta = getResources().obtainAttributes(attrs,R.styleable.HeartProgressBar);
unReachedColor = ta.getColor(R.styleable.HeartProgressBar_UnReachedColor,UNREACHEDCOLOR_DEFAULT);
reachedColor = ta.getColor(R.styleable.HeartProgressBar_ReachedColor,REACHEDCOLOR_DEFAULT);
innerTextColor = ta.getColor(R.styleable.HeartProgressBar_InnerTextColor,INNERTEXTCOLOR_DEFAULT);
innerTextSize = (int) ta.getDimension(R.styleable.HeartProgressBar_InnerTextSize,INNERTEXTSIZE_DEFAULT);
progress = ta.getInt(R.styleable.HeartProgressBar_Progress,PROGRESS_DEFAULT);
ta.recycle();
Log.i("nowColor",progress+"");
//聲明區
underPaint = new Paint();
textPaint = new Paint();
path = new Path();
//構造畫筆區
underPaint.setStyle(Paint.Style.FILL_AND_STROKE);
underPaint.setStrokeWidth(5.0f);
textPaint.setColor(innerTextColor);
textPaint.setTextSize(innerTextSize);
textPaint.setTextAlign(Paint.Align.CENTER);
}
@Override
protected synchronized void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int usedHeight = getRealHeight(heightMeasureSpec);
int usedWidth = getRealWidth(widthMeasureSpec);
setMeasuredDimension(usedWidth,usedHeight);
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
realWidth = w;
realHeight = h;
}
@Override
protected synchronized void onDraw(Canvas canvas) {
super.onDraw(canvas);
paddingBottom = getPaddingBottom();
paddingTop = getPaddingTop();
paddingLeft = getPaddingLeft();
paddingRight = getPaddingRight();
float pro = ((float)progress)/100.0f;
Log.i("nowColor","pro"+pro+"");
int nowColor = (int) argbEvaluator.evaluate(pro,unReachedColor,reachedColor);
underPaint.setColor(nowColor);
path.moveTo((float) (0.5*realWidth), (float) (0.17*realHeight));
path.cubicTo((float) (0.15*realWidth), (float) (-0.35*realHeight), (float) (-0.4*realWidth), (float) (0.45*realHeight), (float) (0.5*realWidth),realHeight);
path.moveTo((float) (0.5*realWidth),realHeight);
path.cubicTo((float) (realWidth+0.4*realWidth), (float) (0.45*realHeight),(float) (realWidth-0.15*realWidth), (float) (-0.35*realHeight),(float) (0.5*realWidth), (float) (0.17*realHeight));
path.close();
canvas.drawPath(path,underPaint);
canvas.drawText(String.valueOf(progress),realWidth/2,realHeight/2,textPaint);
}
public int getRealHeight(int heightMeasureSpec) {
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightVal = MeasureSpec.getSize(heightMeasureSpec);
paddingTop = getPaddingTop();
paddingBottom = getPaddingBottom();
if(heightMode == MeasureSpec.EXACTLY){
return paddingTop + paddingBottom + heightVal;
}else if(heightMode == MeasureSpec.UNSPECIFIED){
return (int) (Math.abs(underPaint.ascent()-underPaint.descent()) + paddingTop + paddingBottom);
}else{
return (int) Math.min((Math.abs(underPaint.ascent()-underPaint.descent()) + paddingTop + paddingBottom),heightVal);
}
}
public int getRealWidth(int widthMeasureSpec) {
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthVal = MeasureSpec.getSize(widthMeasureSpec);
paddingLeft = getPaddingLeft();
paddingRight = getPaddingRight();
if(widthMode == MeasureSpec.EXACTLY){
return paddingLeft+paddingRight+widthVal;
}else if(widthMode == MeasureSpec.UNSPECIFIED){
return (int) (Math.abs(underPaint.ascent()-underPaint.descent()) + paddingLeft + paddingRight);
}else{
return (int) Math.min((Math.abs(underPaint.ascent()-underPaint.descent()) + paddingLeft + paddingRight),widthVal);
}
}
@Override
public void setProgress(int progress) {
this.progress = progress;
invalidate();
}
}
系列文章
Android自定義View系列(一)——打造一個愛心進度條
Android自定義View系列(二)——打造一個仿2K游戲搖桿