Android 自定義 View:包含多種狀態的下載用圓形進度條

chengww發表於2019-04-02

前言

最近做專案碰到一個這樣的一個需求:需要一個環形的進度條表示一個下載請求的進度載入。 同時要以各種不同的圖示展現其下載過程中的各個狀態:等待、下載中、暫停、錯誤、完成。

具體狀態對應圖示見下圖:

download_status.png

以上圖示來自www.iconfont.cn/

考慮到其狀態多達 5 種之多。用已有的控制元件組合顯示,然後判斷狀態來控制各圖示的顯示不太合適。 藉此機會,簡單的擼一個這樣的一個自定義控制元件:CircleProgressBar 來溫習下自定義控制元件的知識。

直接拷貝 CircleProgressBar 使用:CircleProgressBar.java

自定義控制元件

首先需要的基礎知識,你需要了解關於安卓自定義控制元件的基本原理、控制元件的繪製過程。 推薦看下官方的相關文件 Custom View Components。注意:文件為英文文件,有牆。

簡單總結下見下表:

custom-components-form.png

搞清楚上面的基礎之後就正式開始自定義控制元件。如果還沒有看過上述文件也可以跟著我把下面的步奏寫一遍。

建立 View

一般自定義 View 都是繼承自 android.view.View。不過既然我們自定義的是 ProgressBar,就沒必要重頭開始了,直接繼承自 android.widget.ProgressBar 。 這樣 setProgress(int progress); 這些基礎方法就沒必要再定義了。So,給我的控制元件取名為 CircleProgressBar extends ProgressBar

觀察上述幾個圖示,除了下載中狀態有進度載入,其形態有所改變外,其餘狀態均為一個靜態圖片。現在只用搞定下載中狀態的圓環進度和繪製中間的兩條豎線即可。

定義自定義屬性

我們在使用 Android SDK 提供的控制元件的時候,可以直接從 .xml 檔案中新建,比如新建一個 LinearLayout:

<LinearLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="horizontal" />
複製程式碼

同時我們還可以直接在 .xml 檔案中配置各種屬性,如上述程式碼中的 android:orientation="horizontal" 。 我們自定義的控制元件當然也要支援配置和一些自定義屬性,所以就必須要這個構造方法:public CircleProgressBar(Context context, AttributeSet attrs) {}。 這個構造方法允許我們在 .xml 檔案中建立和編輯我們自定義控制元件的例項:

public CircleProgressBar(Context context, AttributeSet attrs) {
    this(context, attrs, 0);
}
複製程式碼

同時,為了在 .xml 檔案中定義我們的自定義屬性(eg: color, size, etc.),我們需要新增以下構造方法:

public CircleProgressBar(Context context, AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
}
複製程式碼

defStyleAttr 這個整型變數是一個定義在 res/values/attrs.xml 檔案中的 declare-styleable 值。 基於此,我們需要新建 res/values/attrs.xml 檔案,並定義一些需要用到的自定義屬性。

觀察要實現的外圈進度條,有兩個進度:一個用來表示預設的圓形,另一個表示進度的顏色。所以這裡涉及到兩個進度條顏色寬高的定義。要繪製圓肯定還需要半徑。 故所有定義的屬性如下:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="CircleProgressBar">
        <!--預設圓的顏色-->
        <attr name="defaultColor" format="color" />
        <!--進度條的顏色-->
        <attr name="reachedColor" format="color" />
        <!--預設圓的高度-->
        <attr name="defaultHeight" format="dimension" />
        <!--進度條的高度-->
        <attr name="reachedHeight" format="dimension" />
        <!--圓的半徑-->
        <attr name="radius" format="dimension" />
    </declare-styleable>
</resources>
複製程式碼

這段程式碼宣告瞭 5 個自定義屬性,它們都是屬於 styleable:CircleProgressBar 的。 為了方便起見,一般styleable的name和我們自定義控制元件的類名一樣。自定義控制元件定義好了之後就可以直接使用了。 具體自定義屬性值含義見 xml 裡面的註釋。

在使用中就可以直接設定這些自定義屬性了:

<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" />
複製程式碼

獲取自定義屬性

既然定義了自定義屬性,當然需要獲取到具體使用中設定的自定義屬性。否則定義自定義屬性就沒有意義了。 首先定義成員變數:

private int mDefaultColor;
private int mReachedColor;
private int mDefaultHeight;
private int mReachedHeight;
private int mRadius;
private Paint mPaint;
private Status mStatus = Status.Waiting;
複製程式碼

然後就是獲取成員變數了。還記得我們上文中 Java 程式碼裡面定義的構造方法 public CircleProgressBar(Context context, AttributeSet attrs, int defStyleAttr) {} 嗎? 沒錯,就是在這個方法裡面獲取使用者設定的自定義屬性值:

    public CircleProgressBar(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.CircleProgressBar);
        //預設圓的顏色
        mDefaultColor = typedArray.getColor(R.styleable.CircleProgressBar_defaultColor, Color.parseColor("#D8D8D8"));
        //進度條的顏色
        mReachedColor = typedArray.getColor(R.styleable.CircleProgressBar_reachedColor, Color.parseColor("#1296DB"));
        //預設圓的高度
        mDefaultHeight = typedArray.getDimension(R.styleable.CircleProgressBar_defaultHeight, dp2px(context, 2.5f));
        //進度條的高度
        mReachedHeight = typedArray.getDimension(R.styleable.CircleProgressBar_reachedHeight, dp2px(context, 2.5f));
        //圓的半徑
        mRadius = typedArray.getDimension(R.styleable.CircleProgressBar_radius, dp2px(context, 17));
        typedArray.recycle();

        setPaint();
    }
複製程式碼

當我們在 xml 檔案中建立一個 View 時,所有在 xml 檔案中宣告的屬性都會被傳入到該 View 的上述構造方法中。 通過呼叫 Context 的 obtainStyledAttributes() 方法返回一個 TypedArray 物件。然後直接用 TypedArray 物件獲取自定義屬性的值,第二個引數是獲取不到時取得預設值。 由於 TypedArray 物件是共享的資源,所以在獲取完值之後必須要呼叫 recycle() 方法來回收。

使用 Java 方法設定自定義屬性

上述方法只能通過 xml 檔案設定自定義屬性,只有在 View 被初始化的時候才能獲取到。要想在執行時使用 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 賦值之後,呼叫了 invalidate() 方法,我們自定義控制元件的屬性發生改變之後,控制元件的樣子也可能發生改變,在這種情況下就需要呼叫 invalidate() 方法讓系統去呼叫 View 的 onDraw() 重新繪製。 同樣的,控制元件屬性的改變可能導致控制元件所佔的大小和形狀發生改變,可以呼叫 requestLayout() 來請求測量獲取一個新的佈局位置。 注:如改變某屬性後,確定控制元件不會變更大小和位置,可以不需要呼叫 requestLayout() 方法。同樣,如控制元件不需要重繪,可以不需要呼叫 invalidate() 方法。

獲取基礎的一些屬性,這裡 mStatus 用來表示當前 View 的狀態以對應各種下載狀態。我們用這些狀態來判定如何繪製合適的效果。各狀態用一個內部列舉來表示。

public enum Status {
    Waiting,
    Pause,
    Loading,
    Error,
    Finish
}
複製程式碼

上述 setPaint() 為初始化 paint 方法。用以繪製進度圓環和各靜態 Drawable。附上 setPaint() 方法程式碼:

private void setPaint() {
    mPaint = new Paint();
    //下面是設定畫筆的一些屬性
    mPaint.setAntiAlias(true);//抗鋸齒
    mPaint.setDither(true);//防抖動,繪製出來的圖要更加柔和清晰
    mPaint.setStyle(Paint.Style.STROKE);//設定填充樣式
    /**
     *  Paint.Style.FILL    :填充內部
     *  Paint.Style.FILL_AND_STROKE  :填充內部和描邊
     *  Paint.Style.STROKE  :僅描邊
     */
    mPaint.setStrokeCap(Paint.Cap.ROUND);//設定畫筆筆刷型別
}
複製程式碼

處理 View 的佈局

View 的測量

一個 View 在展示時總是其寬和高,測量 View 就是為了能夠讓自定義的控制元件能夠根據各種不同的情況以合適的寬高去展示。 具體使用到的方法為 onMeasure() 方法。該方法重寫自系統的方法,包含兩個引數:int widthMeasureSpec, int heightMeasureSpec。 這兩個引數包含了兩個重要的資訊:Mode 和 Size。獲取 Mode 和 Size:

int specMode = MeasureSpec.getMode(measureSpec);
int specSize = MeasureSpec.getSize(measureSpec);
複製程式碼

以上程式碼可以獲取 widthMode、heightMode、widthSize、heightSize 共四個引數。

Mode 代表了當前控制元件的父控制元件告訴我們控制元件,你應該按怎樣的方式來佈局。 Mode 有三個可選值:EXACTLY、AT_MOST、UNSPECIFIED。它們的含義是:

  • EXACTLY:父控制元件告訴我們子控制元件了一個確定的大小,你就按這個大小來佈局。比如我們指定了確定的 dp 值和 match_parent 的情況。
  • AT_MOST:當前控制元件不能超過一個固定的最大值,一般是 wrap_content 的情況。
  • UNSPECIFIED:當前控制元件沒有限制,要多大就有多大,這種情況很少出現。

Size 其實就是父佈局傳遞過來的一個大小,父佈局希望當前佈局的大小。

下面是我們程式碼中 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 的寬度計算出具體的值。

接下來就是繪製效果了。

繪製 View

如開始所述:觀察上述幾個圖示,除了下載中狀態有進度載入,其形態有所改變外,其餘狀態均為一個靜態圖片。繪製其餘狀態靜態圖片可以使用: drawable.draw(canvas); 方法。現在說說如何繪製下載中這個狀態。

重寫 onDraw() 方法,然後我們開始繪製圓:

canvas.translate(getPaddingStart(), getPaddingTop());
mPaint.setStyle(Paint.Style.STROKE);
//畫預設圓(邊框)的一些設定
mPaint.setColor(mDefaultColor);
mPaint.setStrokeWidth(mDefaultHeight);
canvas.drawCircle(mRadius, mRadius, mRadius, mPaint);
複製程式碼

通過 canvas.drawCircle(mRadius, mRadius, mRadius, mPaint); 繪製預設狀態下的圓。之後改變畫筆的顏色,根據進度繪製圓弧。

//畫進度條的一些設定
mPaint.setColor(mReachedColor);
mPaint.setStrokeWidth(mReachedHeight);
//根據進度繪製圓弧
float sweepAngle = getProgress() * 1.0f / getMax() * 360;
canvas.drawArc(new RectF(0, 0, mRadius * 2, mRadius * 2), -90, sweepAngle, false, mPaint);
複製程式碼

最後繪製圓中間的兩條豎線下載中狀態就完成了。下面是一個示例,繪製豎線寬度為 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 來繪製不同的狀態即可完成 onDraw() 方法即可。完整 onDraw() 程式碼和相關 dp2px 方法:

@Override
protected synchronized void onDraw(Canvas canvas) {
    super.onDraw(canvas);

    /**
     * 這裡canvas.save();和canvas.restore();是兩個相互匹配出現的,作用是用來儲存畫布的狀態和取出儲存的狀態的
     * 當我們對畫布進行旋轉,縮放,平移等操作的時候其實我們是想對特定的元素進行操作,但是當你用canvas的方法來進行這些操作的時候,其實是對整個畫布進行了操作,
     * 那麼之後在畫布上的元素都會受到影響,所以我們在操作之前呼叫canvas.save()來儲存畫布當前的狀態,當操作之後取出之前儲存過的狀態,
     * (比如:前面元素設定了平移或旋轉的操作後,下一個元素在進行繪製之前執行了canvas.save();和canvas.restore()操作)這樣後面的元素就不會受到(平移或旋轉的)影響
     */
    canvas.save();
    //為了保證最外層的圓弧全部顯示,我們通常會設定自定義view的padding屬性,這樣就有了內邊距,所以畫筆應該平移到內邊距的位置,這樣畫筆才會剛好在最外層的圓弧上
    //畫筆平移到指定paddingLeft, getPaddingTop()位置
    canvas.translate(getPaddingStart(), getPaddingTop());

    int mDiameter = (int) (mRadius * 2);
    if (mStatus == Status.Loading) {
        mPaint.setStyle(Paint.Style.STROKE);
        //畫預設圓(邊框)的一些設定
        mPaint.setColor(mDefaultColor);
        mPaint.setStrokeWidth(mDefaultHeight);
        canvas.drawCircle(mRadius, mRadius, mRadius, mPaint);

        //畫進度條的一些設定
        mPaint.setColor(mReachedColor);
        mPaint.setStrokeWidth(mReachedHeight);
        //根據進度繪製圓弧
        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;
}
複製程式碼

處理使用者互動

由於對於下載更新進度的情況來說,該控制元件只做狀態顯示,所以這一步不需要,要使用的話自己設定點選事件就可以了。

完成品效果 gif:

CircleProgressBarDemo.gif

演示 apk 下載: blog.chengww.com/files/Circl…

原始碼下載:github.com/chengww5217…

相關文章