前言
最近做項(xiàng)目碰到一個這樣的一個需求:需要一個環(huán)形的進(jìn)度條表示一個下載請求的進(jìn)度加載。
同時要以各種不同的圖標(biāo)展現(xiàn)其下載過程中的各個狀態(tài):等待、下載中、暫停、錯誤、完成。
具體狀態(tài)對應(yīng)圖標(biāo)見下圖:

以上圖標(biāo)來自http://www.iconfont.cn/。
考慮到其狀態(tài)多達(dá) 5 種之多。用已有的控件組合顯示,然后判斷狀態(tài)來控制各圖標(biāo)的顯示不太合適。
借此機(jī)會,簡單的擼一個這樣的一個自定義控件:CircleProgressBar 來溫習(xí)下自定義控件的知識。
直接拷貝 CircleProgressBar 使用:CircleProgressBar.java
自定義控件
首先需要的基礎(chǔ)知識,你需要了解關(guān)于安卓自定義控件的基本原理、控件的繪制過程。
推薦看下官方的相關(guān)文檔 Custom View Components。注意:文檔為英文文檔,有墻。
簡單總結(jié)下見下表:

搞清楚上面的基礎(chǔ)之后就正式開始自定義控件。如果還沒有看過上述文檔也可以跟著我把下面的步奏寫一遍。
創(chuàng)建 View
一般自定義 View 都是繼承自 android.view.View。不過既然我們自定義的是 ProgressBar,就沒必要重頭開始了,直接繼承自 android.widget.ProgressBar 。
這樣 setProgress(int progress); 這些基礎(chǔ)方法就沒必要再定義了。So,給我的控件取名為 CircleProgressBar extends ProgressBar。
觀察上述幾個圖標(biāo),除了下載中狀態(tài)有進(jìn)度加載,其形態(tài)有所改變外,其余狀態(tài)均為一個靜態(tài)圖片?,F(xiàn)在只用搞定下載中狀態(tài)的圓環(huán)進(jìn)度和繪制中間的兩條豎線即可。
定義自定義屬性
我們在使用 Android SDK 提供的控件的時候,可以直接從 .xml 文件中新建,比如新建一個 LinearLayout:
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal" />
同時我們還可以直接在 .xml 文件中配置各種屬性,如上述代碼中的 android:orientation="horizontal" 。
我們自定義的控件當(dāng)然也要支持配置和一些自定義屬性,所以就必須要這個構(gòu)造方法:public CircleProgressBar(Context context, AttributeSet attrs) {}。
這個構(gòu)造方法允許我們在 .xml 文件中創(chuàng)建和編輯我們自定義控件的實(shí)例:
public CircleProgressBar(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
同時,為了在 .xml 文件中定義我們的自定義屬性(eg: color, size, etc.),我們需要新增以下構(gòu)造方法:
public CircleProgressBar(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
defStyleAttr 這個整型變量是一個定義在 res/values/attrs.xml 文件中的 declare-styleable 值。
基于此,我們需要新建 res/values/attrs.xml 文件,并定義一些需要用到的自定義屬性。
觀察要實(shí)現(xiàn)的外圈進(jìn)度條,有兩個進(jìn)度:一個用來表示默認(rèn)的圓形,另一個表示進(jìn)度的顏色。所以這里涉及到兩個進(jìn)度條顏色寬高的定義。要繪制圓肯定還需要半徑。
故所有定義的屬性如下:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="CircleProgressBar">
<!--默認(rèn)圓的顏色-->
<attr name="defaultColor" format="color" />
<!--進(jìn)度條的顏色-->
<attr name="reachedColor" format="color" />
<!--默認(rèn)圓的高度-->
<attr name="defaultHeight" format="dimension" />
<!--進(jìn)度條的高度-->
<attr name="reachedHeight" format="dimension" />
<!--圓的半徑-->
<attr name="radius" format="dimension" />
</declare-styleable>
</resources>
這段代碼聲明了 5 個自定義屬性,它們都是屬于 styleable:CircleProgressBar 的。
為了方便起見,一般styleable的name和我們自定義控件的類名一樣。自定義控件定義好了之后就可以直接使用了。
具體自定義屬性值含義見 xml 里面的注釋。
在使用中就可以直接設(shè)置這些自定義屬性了:
<com.chengww.circleprogressdemo.CircleProgressBar
android:layout_width="46dp"
android:layout_height="46dp"
android:padding="6dp"
android:id="@+id/cp_progress"
app:defaultColor="#D8D8D8"
app:reachedColor="#1296DB"
app:defaultHeight="2.5dp"
app:reachedHeight="2.5dp" />
獲取自定義屬性
既然定義了自定義屬性,當(dāng)然需要獲取到具體使用中設(shè)置的自定義屬性。否則定義自定義屬性就沒有意義了。
首先定義成員變量:
private int mDefaultColor;
private int mReachedColor;
private int mDefaultHeight;
private int mReachedHeight;
private int mRadius;
private Paint mPaint;
private Status mStatus = Status.Waiting;
然后就是獲取成員變量了。還記得我們上文中 Java 代碼里面定義的構(gòu)造方法 public CircleProgressBar(Context context, AttributeSet attrs, int defStyleAttr) {} 嗎?
沒錯,就是在這個方法里面獲取用戶設(shè)置的自定義屬性值:
public CircleProgressBar(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.CircleProgressBar);
//默認(rèn)圓的顏色
mDefaultColor = typedArray.getColor(R.styleable.CircleProgressBar_defaultColor, Color.parseColor("#D8D8D8"));
//進(jìn)度條的顏色
mReachedColor = typedArray.getColor(R.styleable.CircleProgressBar_reachedColor, Color.parseColor("#1296DB"));
//默認(rèn)圓的高度
mDefaultHeight = typedArray.getDimension(R.styleable.CircleProgressBar_defaultHeight, dp2px(context, 2.5f));
//進(jìn)度條的高度
mReachedHeight = typedArray.getDimension(R.styleable.CircleProgressBar_reachedHeight, dp2px(context, 2.5f));
//圓的半徑
mRadius = typedArray.getDimension(R.styleable.CircleProgressBar_radius, dp2px(context, 17));
typedArray.recycle();
setPaint();
}
當(dāng)我們在 xml 文件中創(chuàng)建一個 View 時,所有在 xml 文件中聲明的屬性都會被傳入到該 View 的上述構(gòu)造方法中。
通過調(diào)用 Context 的 obtainStyledAttributes() 方法返回一個 TypedArray 對象。然后直接用 TypedArray 對象獲取自定義屬性的值,第二個參數(shù)是獲取不到時取得默認(rèn)值。
由于 TypedArray 對象是共享的資源,所以在獲取完值之后必須要調(diào)用 recycle() 方法來回收。
使用 Java 方法設(shè)置自定義屬性
上述方法只能通過 xml 文件設(shè)置自定義屬性,只有在 View 被初始化的時候才能獲取到。要想在運(yùn)行時使用 Java 方法修改某個屬性值,對某個屬性值(成員變量)新增 Getter 和 Setter 方法即可。
private Status mStatus = Status.Waiting;
public Status getStatus() {
return mStatus;
}
public void setStatus(Status status) {
if (mStatus == status) return;
mStatus = status;
invalidate();
}
注意 setStatus 方法,在為 mStatus 賦值之后,調(diào)用了 invalidate() 方法,我們自定義控件的屬性發(fā)生改變之后,控件的樣子也可能發(fā)生改變,在這種情況下就需要調(diào)用 invalidate() 方法讓系統(tǒng)去調(diào)用 View 的 onDraw() 重新繪制。
同樣的,控件屬性的改變可能導(dǎo)致控件所占的大小和形狀發(fā)生改變,可以調(diào)用 requestLayout() 來請求測量獲取一個新的布局位置。
注:如改變某屬性后,確定控件不會變更大小和位置,可以不需要調(diào)用 requestLayout() 方法。同樣,如控件不需要重繪,可以不需要調(diào)用 invalidate() 方法。
獲取基礎(chǔ)的一些屬性,這里 mStatus 用來表示當(dāng)前 View 的狀態(tài)以對應(yīng)各種下載狀態(tài)。我們用這些狀態(tài)來判定如何繪制合適的效果。各狀態(tài)用一個內(nèi)部枚舉來表示。
public enum Status {
Waiting,
Pause,
Loading,
Error,
Finish
}
上述 setPaint() 為初始化 paint 方法。用以繪制進(jìn)度圓環(huán)和各靜態(tài) Drawable。附上 setPaint() 方法代碼:
private void setPaint() {
mPaint = new Paint();
//下面是設(shè)置畫筆的一些屬性
mPaint.setAntiAlias(true);//抗鋸齒
mPaint.setDither(true);//防抖動,繪制出來的圖要更加柔和清晰
mPaint.setStyle(Paint.Style.STROKE);//設(shè)置填充樣式
/**
* Paint.Style.FILL :填充內(nèi)部
* Paint.Style.FILL_AND_STROKE :填充內(nèi)部和描邊
* Paint.Style.STROKE :僅描邊
*/
mPaint.setStrokeCap(Paint.Cap.ROUND);//設(shè)置畫筆筆刷類型
}
處理 View 的布局
View 的測量
一個 View 在展示時總是其寬和高,測量 View 就是為了能夠讓自定義的控件能夠根據(jù)各種不同的情況以合適的寬高去展示。
具體使用到的方法為 onMeasure() 方法。該方法重寫自系統(tǒng)的方法,包含兩個參數(shù):int widthMeasureSpec, int heightMeasureSpec。
這兩個參數(shù)包含了兩個重要的信息:Mode 和 Size。獲取 Mode 和 Size:
int specMode = MeasureSpec.getMode(measureSpec);
int specSize = MeasureSpec.getSize(measureSpec);
以上代碼可以獲取 widthMode、heightMode、widthSize、heightSize 共四個參數(shù)。
Mode 代表了當(dāng)前控件的父控件告訴我們控件,你應(yīng)該按怎樣的方式來布局。
Mode 有三個可選值:EXACTLY、AT_MOST、UNSPECIFIED。它們的含義是:
- EXACTLY:父控件告訴我們子控件了一個確定的大小,你就按這個大小來布局。比如我們指定了確定的 dp 值和 match_parent 的情況。
- AT_MOST:當(dāng)前控件不能超過一個固定的最大值,一般是 wrap_content 的情況。
- UNSPECIFIED:當(dāng)前控件沒有限制,要多大就有多大,這種情況很少出現(xiàn)。
Size 其實(shí)就是父布局傳遞過來的一個大小,父布局希望當(dāng)前布局的大小。
下面是我們代碼中 onMeasure() 方法的寫法:
@Override
protected synchronized void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int paintHeight = Math.max(mReachedHeight, mDefaultHeight);
if (heightMode != MeasureSpec.EXACTLY) {
int exceptHeight = getPaddingTop() + getPaddingBottom() + mRadius * 2 + paintHeight;
heightMeasureSpec = MeasureSpec.makeMeasureSpec(exceptHeight, MeasureSpec.EXACTLY);
}
if (widthMode != MeasureSpec.EXACTLY) {
int exceptWidth = getPaddingLeft() + getPaddingRight() + mRadius * 2 + paintHeight;
widthMeasureSpec = MeasureSpec.makeMeasureSpec(exceptWidth, MeasureSpec.EXACTLY);
}
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
我們只需要處理寬高沒有精確指定的情況,通過 padding 加上整個圓以及 Paint 的寬度計(jì)算出具體的值。
接下來就是繪制效果了。
繪制 View
如開始所述:觀察上述幾個圖標(biāo),除了下載中狀態(tài)有進(jìn)度加載,其形態(tài)有所改變外,其余狀態(tài)均為一個靜態(tài)圖片。繪制其余狀態(tài)靜態(tài)圖片可以使用:
drawable.draw(canvas); 方法?,F(xiàn)在說說如何繪制下載中這個狀態(tài)。
重寫 onDraw() 方法,然后我們開始繪制圓:
canvas.translate(getPaddingStart(), getPaddingTop());
mPaint.setStyle(Paint.Style.STROKE);
//畫默認(rèn)圓(邊框)的一些設(shè)置
mPaint.setColor(mDefaultColor);
mPaint.setStrokeWidth(mDefaultHeight);
canvas.drawCircle(mRadius, mRadius, mRadius, mPaint);
通過 canvas.drawCircle(mRadius, mRadius, mRadius, mPaint); 繪制默認(rèn)狀態(tài)下的圓。之后改變畫筆的顏色,根據(jù)進(jìn)度繪制圓弧。
//畫進(jìn)度條的一些設(shè)置
mPaint.setColor(mReachedColor);
mPaint.setStrokeWidth(mReachedHeight);
//根據(jù)進(jìn)度繪制圓弧
float sweepAngle = getProgress() * 1.0f / getMax() * 360;
canvas.drawArc(new RectF(0, 0, mRadius * 2, mRadius * 2), -90, sweepAngle, false, mPaint);
最后繪制圓中間的兩條豎線下載中狀態(tài)就完成了。下面是一個示例,繪制豎線寬度為 2/5 半徑(1/5 + 1/5),高度為 1/2 半徑(1/2 + 1/2):
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeWidth(dp2px(getContext(), 2));
mPaint.setColor(Color.parseColor("#667380"));
canvas.drawLine(mRadius * 4 / 5, mRadius * 3 / 4, mRadius * 4 / 5, 2 * mRadius - (mRadius * 3 / 4), mPaint);
canvas.drawLine(2 * mRadius - (mRadius * 4 / 5), mRadius * 3 / 4, 2 * mRadius - (mRadius * 4 / 5), 2 * mRadius - (mRadius * 3 / 4), mPaint);
然后通過判斷 mStatus 來繪制不同的狀態(tài)即可完成 onDraw() 方法即可。完整 onDraw() 代碼和相關(guān) dp2px 方法:
@Override
protected synchronized void onDraw(Canvas canvas) {
super.onDraw(canvas);
/**
* 這里canvas.save();和canvas.restore();是兩個相互匹配出現(xiàn)的,作用是用來保存畫布的狀態(tài)和取出保存的狀態(tài)的
* 當(dāng)我們對畫布進(jìn)行旋轉(zhuǎn),縮放,平移等操作的時候其實(shí)我們是想對特定的元素進(jìn)行操作,但是當(dāng)你用canvas的方法來進(jìn)行這些操作的時候,其實(shí)是對整個畫布進(jìn)行了操作,
* 那么之后在畫布上的元素都會受到影響,所以我們在操作之前調(diào)用canvas.save()來保存畫布當(dāng)前的狀態(tài),當(dāng)操作之后取出之前保存過的狀態(tài),
* (比如:前面元素設(shè)置了平移或旋轉(zhuǎn)的操作后,下一個元素在進(jìn)行繪制之前執(zhí)行了canvas.save();和canvas.restore()操作)這樣后面的元素就不會受到(平移或旋轉(zhuǎn)的)影響
*/
canvas.save();
//為了保證最外層的圓弧全部顯示,我們通常會設(shè)置自定義view的padding屬性,這樣就有了內(nèi)邊距,所以畫筆應(yīng)該平移到內(nèi)邊距的位置,這樣畫筆才會剛好在最外層的圓弧上
//畫筆平移到指定paddingLeft, getPaddingTop()位置
canvas.translate(getPaddingStart(), getPaddingTop());
int mDiameter = (int) (mRadius * 2);
if (mStatus == Status.Loading) {
mPaint.setStyle(Paint.Style.STROKE);
//畫默認(rèn)圓(邊框)的一些設(shè)置
mPaint.setColor(mDefaultColor);
mPaint.setStrokeWidth(mDefaultHeight);
canvas.drawCircle(mRadius, mRadius, mRadius, mPaint);
//畫進(jìn)度條的一些設(shè)置
mPaint.setColor(mReachedColor);
mPaint.setStrokeWidth(mReachedHeight);
//根據(jù)進(jìn)度繪制圓弧
float sweepAngle = getProgress() * 1.0f / getMax() * 360;
canvas.drawArc(new RectF(0, 0, mRadius * 2, mRadius * 2), -90, sweepAngle, false, mPaint);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeWidth(dp2px(getContext(), 2));
mPaint.setColor(Color.parseColor("#667380"));
canvas.drawLine(mRadius * 4 / 5, mRadius * 3 / 4, mRadius * 4 / 5, 2 * mRadius - (mRadius * 3 / 4), mPaint);
canvas.drawLine(2 * mRadius - (mRadius * 4 / 5), mRadius * 3 / 4, 2 * mRadius - (mRadius * 4 / 5), 2 * mRadius - (mRadius * 3 / 4), mPaint);
} else {
int drawableInt;
switch (mStatus) {
case Waiting:
default:
drawableInt = R.mipmap.ic_waiting;
break;
case Pause:
drawableInt = R.mipmap.ic_pause;
break;
case Finish:
drawableInt = R.mipmap.ic_finish;
break;
case Error:
drawableInt = R.mipmap.ic_error;
break;
}
Drawable drawable = getContext().getResources().getDrawable(drawableInt);
drawable.setBounds(0, 0, mDiameter, mDiameter);
drawable.draw(canvas);
}
canvas.restore();
}
float dp2px(Context context, float dp) {
final float scale = context.getResources().getDisplayMetrics().density;
return dp * scale + 0.5f;
}
處理用戶交互
由于對于下載更新進(jìn)度的情況來說,該控件只做狀態(tài)顯示,所以這一步不需要,要使用的話自己設(shè)置點(diǎn)擊事件就可以了。
完成品效果 gif:

演示 apk 下載:
https://blog.chengww.com/files/CircleProgressBarDemo_1.0.apk
源碼下載:https://github.com/chengww5217/CircleProgressBarDemo
文章作者: chengww
文章鏈接: https://chengww.com/archives/CircleProgressBar.html
版權(quán)聲明: 本博客所有文章除特別聲明外,均采用 CC BY-NC-SA 4.0 許可協(xié)議。轉(zhuǎn)載請注明來自 chengww's blog!