自定義View實戰--實現一個清新美觀的載入按鈕
本篇文章已授權微信公眾號 guolin_blog (郭霖)獨家釋出
在 Dribble 上偶然看到了一組互動如下:
當時在心裡問自己能不能做,答案肯定是能做的,不過我比較懶,覺得中間那個伸縮變化要編寫很多程式碼,所以懶得理。後來,為了不讓自己那麼浮躁,也為了鍛鍊自己的耐心程度,還是堅持實現它了。這個過程,覺得自己還是有所收穫,把握了一些想當然的細節,輸理了對於自定義 View 的流程。
我將這個自定義 View,起了一個名字叫做 LoadButton。
這篇文章涉及到的知識點有如下:
1. 自定義 View 時的基本流程,包含 attrs.xml 中屬性的編寫,構造方法中屬性的獲取,onMeasure() 中尺寸的測量。onDraw() 中介面的實現。
2. 可以讓 Android 初學者再次感受一次回撥機制的美妙。
3. 屬性動畫的基本使用。
第一步,先確定尺寸
先觀察 LoadView 的形態。
上面的顯示的是兩種形狀,一個是圓角矩形,另外一個就是圓。兩個形態尺寸區別是,高相同,寬度不一致。
我們再進一步分析形態 1。
形態 1 可以看成是左右兩個半圓和中間一個矩形。再回顧下示例圖片中的動畫表現。
圓角矩形最終變成了一個圓。我們可以用線框圖來漸進表現它。
當進行動畫時,中間的矩形部分不停地縮小,當它縮小為 0 時,形態 1 就轉變成了形態 2。
上面的能夠說明什麼呢?說明 LoadButton 由 3 個部分組成,左右的半圓和中間的矩形,即使是形態 2 也可以看做是左右半圓和中間寬度為 0 的矩形組成。
細化尺寸
我們進一步討論尺寸相關的情況。
我們知道對於普通開發者而言,自定義一個 View 測量尺寸的時候我們通常要關注的測量模式是 MeasureSpec.EXACTLY 和 MeasureSpec.AT_MOST 兩種。要了解更多詳細的資訊可以閱讀我寫的這篇博文《長談:關於 View Measure 測量機制,讓我一次把話說完》。接下來,我們詳細討論一下這兩種情況。
MeasureSpec.EXACTLY
當一個 View 的 layout_width 或者 layout_height 的取值為 match_parent 或 30dp 這樣具體的數值時,這就表明它的測量模式是 MeasureSpec.EXACTLY。它已經獲得了精確的數值了,按照常理我們是不應該再去幹涉它,parent 給出的建議尺寸是什麼,我們就把尺寸設定成什麼,但是結合開發的實際情況來看,我們有一個底線,為了保證 LoadView 的完整性,也就是再差的情況下,parent 給出來的建議尺寸也不能小於形態 2。否則如下圖情況就不是我們想要的了
MeasureSpec.AT_MOST
當一個 View 的 layout_width 或者 layout_height 的取值為 wrap_content 時,它的測量模式就是 MeasureSpec.AT_MOST,這個時候我們需要自己根據內容計算尺寸。而 LoadButton 的內容是什麼呢?它的內容有 text 還有 載入成功或者載入失敗的圖片。因為圖片大小在形態 2 中的圓形內可以確認。所以問題的關鍵就在於 LoadButton 文字內容寬高的尺寸測量。
text 內容自然是居中顯示,然後它距離中間的 rect 上下左右間距也要考慮。這個時候的 rect 尺寸就是相對應的文字尺寸加上相對應方向上的 padding 值,這些 padding 值通過在 attrs.xml 中自定義屬性然後在佈局檔案中賦予。
最後整體 LoadButton 尺寸自然是中間 rect 加上左右兩個半圓的半徑,但是這還不是最終的尺寸,最終的尺寸還是要和 parent 給的建議尺寸比較,不能大於它。
上面分析了尺寸測量相關,所以順著思路進行的話,編碼也只是水到渠成的事情了。
public class LoadButton extends View {
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
//用於儲存最終尺寸
int resultW = widthSize;
int resultH = heightSize;
// contentW contentH 用於確定中間矩形的尺寸
int contentW = 0;
int contentH = 0;
if ( widthMode == MeasureSpec.AT_MOST ) {
mTextWidth = (int) mTextPaint.measureText(mText);
contentW += mTextWidth + mLeftRightPadding * 2 + mRadiu * 2;
resultW = contentW < widthSize ? contentW : widthSize;
}
if ( heightMode == MeasureSpec.AT_MOST ) {
contentH += mTopBottomPadding * 2 + mTextSize;
resultH = contentH < heightSize ? contentH : heightSize;
}
resultW = resultW < 2 * mRadiu ? 2 * mRadiu : resultW;
resultH = resultH < 2 * mRadiu ? 2 * mRadiu : resultH;
// 修整圓形的半徑
mRadiu = resultH / 2;
// 記錄中間矩形的寬度值
rectWidth = resultW - 2 * mRadiu;
setMeasuredDimension(resultW,resultH);
Log.d(TAG,"onMeasure: w:"+resultW+" h:"+resultH);
}
}
第二步,繪製
測量是在 onMeasure() 方法中進行,而繪製就是在 onDraw() 方法中進行的,這是 Android 開發者都知道的事情。所以這一節的重點在於 onDraw() 這個方法。
為了不給讀者造成困擾,我先張貼自定的屬性,及在構造方法中獲取屬性值的程式碼。其它的細節應該看名字就大概知道了。
attrs.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="LoadButton">
<attr name="android:text" />
<attr name="android:textSize" />
<attr name="stroke_color" format="color|reference" />
<attr name="content_color" format="color|reference" />
<attr name="radiu" format="dimension|reference" />
<attr name="rectwidth" format="dimension|reference" />
<attr name="contentPaddingLR" format="dimension|reference" />
<attr name="contentPaddingTB" format="dimension|reference" />
<attr name="progressedWidth" format="dimension|reference" />
<attr name="backColor" format="color|reference" />
<attr name="progressColor" format="color|reference" />
<attr name="progressSecondColor" format="color|reference" />
<attr name="loadSuccessDrawable" format="reference" />
<attr name="loadErrorDrawable" format="reference" />
<attr name="loadPauseDrawable" format="reference" />
</declare-styleable>
</resources>
然後在 LoadButton 的構造方法中獲取這些值。
public LoadButton(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mDefaultRadiu = 40;
mDefaultTextSize = 24;
TypedArray typedArray = context.obtainStyledAttributes(attrs,R.styleable.LoadButton);
mTextSize = typedArray.getDimensionPixelSize(R.styleable.LoadButton_android_textSize,
mDefaultTextSize);
mStrokeColor = typedArray.getColor(R.styleable.LoadButton_stroke_color, Color.RED);
mTextColor = typedArray.getColor(R.styleable.LoadButton_content_color, Color.WHITE);
mText = typedArray.getString(R.styleable.LoadButton_android_text);
mRadiu = typedArray.getDimensionPixelOffset(R.styleable.LoadButton_radiu,mDefaultRadiu);
mTopBottomPadding = typedArray.getDimensionPixelOffset(R.styleable.LoadButton_contentPaddingTB,10);
mLeftRightPadding = typedArray.getDimensionPixelOffset(R.styleable.LoadButton_contentPaddingLR,10);
mBackgroundColor = typedArray.getColor(R.styleable.LoadButton_backColor,Color.WHITE);
mProgressColor = typedArray.getColor(R.styleable.LoadButton_progressColor,Color.WHITE);
mProgressSecondColor = typedArray.getColor(R.styleable.LoadButton_progressSecondColor,Color.parseColor("#c3c3c3"));
mProgressWidth = typedArray.getDimensionPixelOffset(R.styleable.LoadButton_progressedWidth,2);
mSuccessedDrawable = typedArray.getDrawable(R.styleable.LoadButton_loadSuccessDrawable);
mErrorDrawable = typedArray.getDrawable(R.styleable.LoadButton_loadErrorDrawable);
mPauseDrawable = typedArray.getDrawable(R.styleable.LoadButton_loadPauseDrawable);
typedArray.recycle();
......
}
形態 1 的繪製,藉助於 Path 的力量
Android 繪製圖形離不開 Canvas,Canvas 可以直接繪製 直線、矩形、圓、橢圓,但是 LoadButton 的形態 1 怎麼繪製呢?它是一個不規則的閉合圖形,直接用 Canvas 的話肯定不行,所以得藉助另外一個類 Path,Path 中文譯做路徑,可以專門處理這種情況,而且可以處理比這複雜的情況,具體情況請讀者們自己查閱相應資料與教程。
我們再來觀察 形態 1 到形態 2 的轉變過程。
這是個中間矩形從初始值變為 0 的過程,我們用 rectWidth 表示這個矩形的寬度值,因為在 onDraw() 方法中,LoadButton 尺寸確定,所以我們很容易得到它的中心點,所以我們可以中心點座標為參考座標,然後以 rectWidth 為變數建立一個 path,這個 path 實現了 LoadButton 的輪廓。
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int cx = getWidth() / 2;
int cy = getHeight() / 2;
drawPath(canvas,cx,cy);
.....
}
private void drawPath(Canvas canvas,int cx,int cy) {
if (mPath == null) {
mPath = new Path();
}
mPath.reset();
left = cx - rectWidth / 2 - mRadiu;
top = 0;
right = cx + rectWidth / 2 + mRadiu;
bottom = getHeight();
leftRect.set(left,top,left + mRadiu * 2,bottom);
rightRect.set(right - mRadiu * 2,top,right,bottom);
contentRect.set(cx-rectWidth/2,top,cx + rectWidth/2,bottom);
//path 起始位置
mPath.moveTo(cx - rectWidth /2,bottom);
// 左邊半圓
mPath.arcTo(leftRect,
90.0f,180f);
//連線到右邊半圓
mPath.lineTo(cx + rectWidth/2,top);
// 右邊半圓
mPath.arcTo(rightRect,
270.0f,180f);
// path 閉合
mPath.close();
// 以填充的方向將圖形填充為指定的背景色
mPaint.setStyle(Paint.Style.FILL);
mPaint.setColor(mBackgroundColor);
canvas.drawPath(mPath,mPaint);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setColor(mStrokeColor);
}
以 rectWidth 為變數建立 path 的好處時,當從形態 1 到 形態 2 轉變的過程,肯定是 rectWidth 數值變化的過程,而對於其它數值是不變的,所以重繪的時候 LoadButton 能夠很輕鬆地處理這種情況。
我們到這一步的時候已經能夠準確地繪製了 LoadButton 的輪廓。現在需要精確地繪製它的內容,只有這樣才是完整的 LoadButton。
我們先需要給 LoadButton 定義一些狀態。
LoadButton 的狀態
enum State {
INITIAL,// 初始狀態
FOLDING,// 正在伸縮
LOADING, // 正在載入
ERROR,// 載入失敗
SUCCESSED,// 載入成功
PAUSED // 載入暫停
}
它們的狀態轉換如下:
LoadButton 的狀態轉換由使用者點選按鈕觸發。所以 LoadButton 需要在內部設定一個 OnClickListenner。
1. 當在 Initial 狀態下點選時,它會轉換到 Folding 狀態下。
2. Foding 狀態結束後,由形態 1 轉變成形態 2。自然就進入了 Loading 狀態。
3. Loading 狀態有 3 個走向,載入成功後,使用者通過相應 API 設定狀態為 Successed。載入失敗後,使用者可以設定狀態為 Error。如果在 Loading 狀態下點選按鈕,會進入 Paused 狀態。
4. 在 Paused 狀態下點選按鈕,LoadButton 重新進入 Loading 狀態。
5. 在 Successed 或者 Error 狀態下點選按鈕,將通過回撥物件,通知呼叫者點選事件的發生。
我們在 LoadButton 的構造方法中設定這樣的內部的 OnClickListenner。
public LoadButton(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
......
isUnfold = true;
mListenner = new OnClickListener() {
@Override
public void onClick(View v) {
if ( mCurrentState == State.FODDING) {
return;
}
if ( mCurrentState == State.INITIAL ) {
if ( isUnfold ) {
shringk();
}
} else if ( mCurrentState == State.ERROR) {
if (mLoadListenner != null ) {
mLoadListenner.onClick(false);
}
} else if ( mCurrentState == State.SUCCESSED ) {
if (mLoadListenner != null ) {
mLoadListenner.onClick(true);
}
} else if ( mCurrentState == State.PAUSED) {
if (mLoadListenner != null ) {
mLoadListenner.needLoading();
load();
}
} else if ( mCurrentState == State.LOADDING) {
mCurrentState = State.PAUSED;
cancelAnimation();
invaidateSelft();
}
}
};
setOnClickListener(mListenner);
mCurrentState = State.INITIAL;
......
}
狀態的繪製
Initial 狀態下其實就是中間一個 text 文字居中顯示,相關程式碼如下:
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
......
int textDescent = (int) mTextPaint.getFontMetrics().descent;
int textAscent = (int) mTextPaint.getFontMetrics().ascent;
int delta = Math.abs(textAscent) - textDescent;
if ( mCurrentState == State.INITIAL) {
canvas.drawText(mText,cx,cy + delta / 2,mTextPaint);
}
.....
}
Folding 狀態其實就是不顯示文字的 Inital 狀態,不同的還有它的 rectwidth 每次重繪時會變小,最終會由 Initial 的形態 1 過渡到 Loading 狀態下的形態 2。在 Initial 狀態下點選按鈕會呼叫一個動畫,這個動畫用於展示形態 1 到形態 2 的過程。
if ( mCurrentState == State.INITIAL ) {
if ( isUnfold ) {
shringk();
}
}
public void shringk() {
if (shrinkAnim == null) {
shrinkAnim = ObjectAnimator.ofInt(this,"rectWidth", rectWidth,0);
}
shrinkAnim.addListener(this);
shrinkAnim.setDuration(500);
shrinkAnim.start();
mCurrentState = State.FOLDING;
}
public void setRectWidth (int width) {
rectWidth = width;
invaidateSelft();
}
private void invaidateSelft() {
if (Looper.myLooper() == Looper.getMainLooper()) {
invalidate();
} else {
postInvalidate();
}
}
這裡是一個典型的屬性動畫應用場景,通過不斷改變屬性 rectWidth 的值來進行重繪,而對於繪製這一方面,文章前面部分有說過 LoadButton 通過以中心座標為參考,以 mRectWidth 為變數建立了一個 Path 來繪製輪廓。
另外,大家可以注意到,shrinkAnim 有一個監聽器,我設定為了 LoadButton 本身。
public class LoadButton extends View implements Animator.AnimatorListener {
@Override
public void onAnimationStart(Animator animation) {
}
@Override
public void onAnimationEnd(Animator animation) {
isUnfold = false;
load();
}
@Override
public void onAnimationCancel(Animator animation) {
}
@Override
public void onAnimationRepeat(Animator animation) {
}
}
在收縮動畫結束的時候,我呼叫了 load() 方法用來將狀態設定為 Loading,並進行載入動畫。
我們先看看 Loading 狀態下的繪製,它是形態 2 ,也就是在一個圓形內有一個正在載入無限迴圈的動畫。思路也很簡單,用進度條的背景色畫一個圓圈,然後用進度條的前景色繪製相應角度的弧,並且這個弧的半徑和進度條的半徑一樣。
if ( mCurrentState == State.LOADING) {
if ( progressRect == null ) {
progressRect = new RectF();
}
progressRect.set(cx - circleR,cy - circleR,cx + circleR,cy + circleR);
mPaint.setColor(mProgressSecondColor);
//先繪製背景圓
canvas.drawCircle(cx,cy,circleR,mPaint);
mPaint.setColor(mProgressColor);
Log.d(TAG,"onDraw() pro:"+progressReverse+" swpeep:"+circleSweep);
if ( circleSweep != 360 ) {
mProgressStartAngel = progressReverse ? 270 : (int) (270 + circleSweep);
//繪製弧線
canvas.drawArc(progressRect
,mProgressStartAngel,progressReverse ? circleSweep : (int) (360 - circleSweep),
false,mPaint);
}
mPaint.setColor(mBackgroundColor);
}
上面有兩個關鍵的變數 progressReverse 和 circleSweep。progressReverse 用來表示動畫是否需要翻轉,circleSweep 表示每次繪製的時候從起始角度掃描的角度。
正常情況下,起始角度是 270 度不變,如果動畫翻轉時,它是 270 + circleSweep 的值,具體為什麼這樣做,大家可以觀看之前的影象來思考一下。
載入的動畫自然也是屬性動畫控制的,這個動畫讓 circleSweep 從 0 到 360 之間不停地變化。並且在每次迴圈的時候,將 progressReverse 變數置反。
public void load() {
if (loadAnimator == null) {
loadAnimator = ObjectAnimator.ofFloat(this,"circleSweep",0,360);
}
loadAnimator.setDuration(1000);
loadAnimator.setRepeatMode(ValueAnimator.RESTART);
loadAnimator.setRepeatCount(ValueAnimator.INFINITE);
loadAnimator.removeAllListeners();
loadAnimator.addListener(new Animator.AnimatorListener() {
@Override
public void onAnimationStart(Animator animation) {
}
@Override
public void onAnimationEnd(Animator animation) {
}
@Override
public void onAnimationCancel(Animator animation) {
}
@Override
public void onAnimationRepeat(Animator animation) {
// Log.d(TAG,"onAnimationRepeat:"+progressReverse);
progressReverse = !progressReverse;
}
});
loadAnimator.start();
mCurrentState = State.LOADING;
}
Paused 狀態是當 LoadButton 在 Loading 狀態下,使用者點選了按鈕,這個時候按鈕會顯示一個暫停圖示。
if ( mCurrentState == State.LOADING) {
mCurrentState = State.PAUSED;
cancelAnimation();
invaidateSelft();
}
至於顯示方面,非常簡單就是給一個 drawable 設定好 bound 範圍然後顯示。稍後我會給出程式碼。
Successed 狀態和 Error 狀態實現過程基本上是一致的。但是它們被點選的時候,需要通知點選者。所以我們需要定義一個回撥介面。
if ( mCurrentState == State.ERROR) {
if (mLoadListenner != null ) {
mLoadListenner.onClick(false);
}
} else if ( mCurrentState == State.SUCCESSED ) {
if (mLoadListenner != null ) {
mLoadListenner.onClick(true);
}
} else if ( mCurrentState == State.PAUSED) {
if (mLoadListenner != null ) {
mLoadListenner.needLoading();
load();
}
}else if ( mCurrentState == State.PAUSED) {
if (mLoadListenner != null ) {
mLoadListenner.needLoading();
load();
}
}
public interface LoadListenner {
void onClick(boolean isSuccessed);
void needLoading();
}
LoadListenner.onClick() 方法中的引數,isSuccessed 為真告訴點選者載入成功了的資訊。否則提示載入失敗。needLoading() 方法用來告訴點選者當在 Paused 狀態下點選按鈕時,呼叫者應該重新載入了。
它們的顯示程式碼如下:
if ( mCurrentState == State.ERROR) {
mErrorDrawable.setBounds(cx - circleR,cy - circleR,cx + circleR,cy + circleR);
mErrorDrawable.draw(canvas);
} else if (mCurrentState == State.SUCCESSED) {
mSuccessedDrawable.setBounds(cx - circleR,cy - circleR,cx + circleR,cy + circleR);
mSuccessedDrawable.draw(canvas);
} else if (mCurrentState == State.PAUSED) {
mPauseDrawable.setBounds(cx - circleR,cy - circleR,cx + circleR,cy + circleR);
mPauseDrawable.draw(canvas);
}
另外,需要注意的是 Successed 和 Error 狀態,需要開發者根據實際情況決定呼叫。
public void loadSuccessed() {
mCurrentState = State.SUCCESSED;
cancelAnimation();
invaidateSelft();
}
public void loadFailed() {
mCurrentState = State.ERROR;
cancelAnimation();
invaidateSelft();
}
將 LoadButton 重置為 Initial 狀態用 reset() 方法。
public void reset(){
mCurrentState = State.INITIAL;
rectWidth = getWidth() - mRadiu * 2;
isUnfold = true;
cancelAnimation();
invaidateSelft();
}
到此,整個 LoadButton 實現邏輯已經完成。接下來我們可以編寫程式碼測試。
測試
我們新增一個 LoadButton 到佈局檔案,然後用 3 個 Button 來測試它成功、失敗、重置的情況。
佈局檔案
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/activity_main"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="20dp"
android:orientation="vertical"
tools:context="com.frank.statusbuttondemo.MainActivity">
<com.frank.statusbuttondemo.LoadButton
android:id="@+id/btn_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:backColor="#009966"
app:contentPaddingLR="20dp"
app:contentPaddingTB="20dp"
app:content_color="@android:color/white"
app:progressedWidth="4dp"
android:textSize="36sp"
android:text="點選載入" />
<Button
android:id="@+id/btn_test_successed"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="載入成功"/>
<Button
android:id="@+id/btn_test_error"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="載入失敗"/>
<Button
android:id="@+id/btn_reset"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="重置按鈕"/>
</LinearLayout>
MainActivity.java
public class MainActivity extends AppCompatActivity implements View.OnClickListener {
LoadButton mLoadButton;
Button mBtnSuccessed,mBtnError,mBtnReset;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
mLoadButton = (LoadButton) findViewById(R.id.btn_status);
mBtnSuccessed = (Button) findViewById(R.id.btn_test_successed);
mBtnError = (Button) findViewById(R.id.btn_test_error);
mBtnReset = (Button) findViewById(R.id.btn_reset);
mBtnError.setOnClickListener(this);
mBtnSuccessed.setOnClickListener(this);
mBtnReset.setOnClickListener(this);
mLoadButton.setListenner(new LoadButton.LoadListenner() {
@Override
public void onClick(boolean isSuccessed) {
if ( isSuccessed ) {
Toast.makeText(MainActivity.this,"載入成功",Toast.LENGTH_LONG).show();
} else {
Toast.makeText(MainActivity.this,"載入失敗",Toast.LENGTH_LONG).show();
}
}
@Override
public void needLoading() {
Toast.makeText(MainActivity.this,"重新下載",Toast.LENGTH_LONG).show();
}
});
}
@Override
public void onClick(View v) {
switch (v.getId())
{
case R.id.btn_test_successed:
mLoadButton.loadSuccessed();
break;
case R.id.btn_test_error:
mLoadButton.loadFailed();
break;
case R.id.btn_reset:
mLoadButton.reset();
break;
default:
break;
}
}
}
測試結果:
總結
本文的主題並不難,但是如果要實現它也需要細心。關鍵是編碼的時候,要先設計分析,之後就是一氣呵成、水到渠成的事情了。
通過演練這個專案,我覺得自己還是有些收穫。
- 複習了自定義 View 的基本流程。特別是對 onMeasure() 這一塊有更深的理解。
- 複習了屬性動畫的使用。
- 複習了 Canvas 和 Path 的基本用法。
- 演練了狀態模式下的程式設計。
- 享受回撥機制帶來的美妙感受。
如果有人認為好用,我想把它上傳到 jcenter 倉庫,目的也是為了演練怎麼上傳 Android 模組到開源庫。喜歡這篇文章就給我一個贊吧,需要你們的鼓勵。哈哈。