Android 自定義 View:包含多種狀態(tài)的下載用圓形進(jìn)度條

前言

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

具體狀態(tài)對應(yīng)圖標(biāo)見下圖:


download_status.png

以上圖標(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é)下見下表:


custom-components-form.png

搞清楚上面的基礎(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:

CircleProgressBarDemo.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!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容