Android自定義View之圓形進度條總結

rbinloacmy 7年前發布 | 6K 次閱讀 安卓開發 Android開發 移動開發

最近擼了一個圓形進度條的開源項目,算是第一次完完整整的使用自定義 View 。在此對項目開發思路做個小結,歡迎大家 Star 和 Fork。

該項目總共實現了三種圓形進度條效果

  1. CircleProgress:圓形進度條,可以實現仿 QQ 健康計步器的效果,支持配置進度條背景色、寬度、起始角度,支持進度條漸變
  2. DialProgress:類似 CircleProgress,但是支持刻度
  3. WaveProgress:實現了水波紋效果的圓形進度條,不支持漸變和起始角度配置,如需此功能可參考 CircleProgress 自行實現。

先上效果圖,有圖才好說。

CircleProgress 效果圖

DialProgress 和 WaveProgress 效果圖

恩,那么接下來,就來講講怎么實現以上自定義進度條的效果。

圓形進度條

圓形進度條是第一個實現的進度條效果,用了我大半天的時間,實現起來并不復雜。

其思路主要可以分為以下幾步:

  1. View 的測量
  2. 計算繪制 View 所需參數
  3. 圓弧的繪制及漸變的實現
  4. 文字的繪制
  5. 動畫效果的實現

首先,我們要測量出所繪制 View 的大小,即重寫 onMeasure() 方法,代碼如下:

@Override 
 
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 
 
   super.onMeasure(widthMeasureSpec, heightMeasureSpec); 
 
   setMeasuredDimension(MiscUtil.measure(widthMeasureSpec, mDefaultSize), 
 
           MiscUtil.measure(heightMeasureSpec, mDefaultSize)); 
 
}  

由于其他兩個進度條類都需要實現 View 的測量,這里對代碼進行了封裝:

/** 
 
* 測量 View 
 
* 
 
* @param measureSpec 
 
* @param defaultSize View 的默認大小 
 
* @return 測量出來的 View 大小 
 
*/ 
 
public static int measure(int measureSpec, int defaultSize) { 
 
   int result = defaultSize; 
 
   int specMode = View.MeasureSpec.getMode(measureSpec); 
 
   int specSize = View.MeasureSpec.getSize(measureSpec); 
 
  
 
   if (specMode == View.MeasureSpec.EXACTLY) { 
 
       result = specSize; 
 
   } else if (specMode == View.MeasureSpec.AT_MOST) { 
 
       result = Math.min(result, specSize); 
 
   } 
 
   return result; 
 
}  

關于 View 測量可以看下這篇博客 Android 自定義View 中的onMeasure的用法

接下來,在 onSizeChanged() 中計算繪制圓及文字所需的參數,考慮到屏幕旋轉的情況,故未直接在 onMeasure() 方法中直接計算。這里以下面草圖來講解繪制計算過程中的注意事項,圖丑,勿怪~

圖中,外面藍色矩形為 View,里面黑色矩形為圓的外接矩形,藍色矩形和黑色矩形中間空白的地方為 View 的內邊距(padding)。兩個藍色的圓其實是一個圓,代表圓的粗細,這是因為 Android 在繪制圓或者圓弧的時候是圓的邊寬的中心與外接矩形相交,所以在計算的時候要考慮到內邊距(padding) 和 圓與外接矩形的相交。

默認不考慮圓弧的寬度,繪制出來的效果如下:

@Override 
 
protected void onSizeChanged(int w, int h, int oldw, int oldh) { 
 
   super.onSizeChanged(w, h, oldw, oldh); 
 
   Log.d(TAG, "onSizeChanged: w = " + w + "; h = " + h + "; oldw = " + oldw + "; oldh = " + oldh); 
 
   //求圓弧和背景圓弧的最大寬度 
 
   float maxArcWidth = Math.max(mArcWidth, mBgArcWidth); 
 
   //求最小值作為實際值 
 
   int minSize = Math.min(w - getPaddingLeft() - getPaddingRight() - 2 * (int) maxArcWidth, 
 
           h - getPaddingTop() - getPaddingBottom() - 2 * (int) maxArcWidth); 
 
   //減去圓弧的寬度,否則會造成部分圓弧繪制在外圍 
 
   mRadius = minSize / 2; 
 
   //獲取圓的相關參數 
 
   mCenterPoint.x = w / 2; 
 
   mCenterPoint.y = h / 2; 
 
   //繪制圓弧的邊界 
 
   mRectF.left = mCenterPoint.x - mRadius - maxArcWidth / 2; 
 
   mRectF.top = mCenterPoint.y - mRadius - maxArcWidth / 2; 
 
   mRectF.right = mCenterPoint.x + mRadius + maxArcWidth / 2; 
 
   mRectF.bottom = mCenterPoint.y + mRadius + maxArcWidth / 2; 
 
   //計算文字繪制時的 baseline 
 
   //由于文字的baseline、descent、ascent等屬性只與textSize和typeface有關,所以此時可以直接計算 
 
   //若value、hint、unit由同一個畫筆繪制或者需要動態設置文字的大小,則需要在每次更新后再次計算 
 
   mValueOffset = mCenterPoint.y - (mValuePaint.descent() + mValuePaint.ascent()) / 2; 
 
   mHintOffset = mCenterPoint.y * 2 / 3 - (mHintPaint.descent() + mHintPaint.ascent()) / 2; 
 
   mUnitOffset = mCenterPoint.y * 4 / 3 - (mUnitPaint.descent() + mUnitPaint.ascent()) / 2; 
 
   updateArcPaint(); 
 
   Log.d(TAG, "onSizeChanged: 控件大小 = " + "(" + w + ", " + h + ")" 
 
           + "圓心坐標 = " + mCenterPoint.toString() 
 
           + ";圓半徑 = " + mRadius 
 
           + ";圓的外接矩形 = " + mRectF.toString());  

關于 Android 中文字繪制可以參考以下兩篇文章:

1. Android 自定義View學習(三)——Paint 繪制文字屬性

2. measureText() vs .getTextBounds()

以上,已經基本完成了 View 繪制所需全部參數的計算。接下來就是繪制圓弧及文字了。

繪制圓弧需要用到 Canvas 的

// oval 為 RectF 類型,即圓弧顯示區域 
 
// startAngle 和 sweepAngle  均為 float 類型,分別表示圓弧起始角度和圓弧度數。3點鐘方向為0度,順時針遞增 
 
// 如果 startAngle < 0 或者 > 360,則相當于 startAngle % 360 
 
// useCenter:如果為 true 時,在繪制圓弧時將圓心包括在內,通常用來繪制扇形 
 
// 繪制圓弧的畫筆 
 
drawArc(RectF oval, float startAngle, float sweepAngle, boolean useCenter, Paint paint);  

為了方便計算,繪制圓弧的時候使用了 Canvas 的 rotate() 方法,對坐標系進行了旋轉

private void drawArc(Canvas canvas) { 
 
   // 繪制背景圓弧 
 
   // 從進度圓弧結束的地方開始重新繪制,優化性能 
 
   canvas.save(); 
 
   float currentAngle = mSweepAngle * mPercent; 
 
   canvas.rotate(mStartAngle, mCenterPoint.x, mCenterPoint.y); 
 
   // +2 是因為繪制的時候出現了圓弧起點有尾巴的問題 
 
   canvas.drawArc(mRectF, currentAngle, mSweepAngle - currentAngle + 2, false, mBgArcPaint); 
 
   canvas.drawArc(mRectF, 2, currentAngle, false, mArcPaint); 
 
   canvas.restore(); 
 
}  

恩,圓環已經繪制完成,那么接下來就是實現圓環的漸變,這里使用 SweepGradient 類。SweepGradient 可以實現從中心放射性漸變的效果,如下圖:

SweepGradient 類有兩個構造方法,

/** 
 
* @param cx 渲染中心點x坐標 
 
* @param cy 渲染中心點y坐標 
 
* @param colors 圍繞中心渲染的顏色數組,至少要有兩種顏色值 
 
* @param positions 相對位置的顏色數組,可為null,  若為null,可為null,顏色沿漸變線均勻分布。一般不需要設置該參數 
 
/ 
 
public SweepGradient(float cx, float cy, int[] colors, float[] positions) 
 
  
 
/** 
 
* @param cx 渲染中心點x坐標 
 
* @param cy 渲染中心點y坐標 
 
* @param color0 起始渲染顏色 
 
* @param color1 結束渲染顏色 
 
/ 
 
public SweepGradient(float cx, float cy, int color0, int color1)  

這里我們選擇第一個構造方法。由于設置漸變需要每次都創建一個新的 SweepGradient 對象,所以最好不要放到 onDraw 方法中去更新,最好在初始化的時候就設置好,避免頻繁創建導致內存抖動。

private void updateArcPaint() { 
 
   // 設置漸變 
 
   int[] mGradientColors = {Color.GREEN, Color.YELLOW, Color.RED}; 
 
   mSweepGradient = new SweepGradient(mCenterPoint.x, mCenterPoint.y, mGradientColors, null); 
 
   mArcPaint.setShader(mSweepGradient); 
 
}  

這里還有一個值得注意的地方,草圖如下

假設,漸變顏色如下:

int[] mGradientColors = {Color.GREEN, Color.YELLOW, Color.RED, Color.BLUE}; 

因為 SweepGradient 漸變是 360 度的,所以如果你繪制的圓弧只有 270度,則藍色部分(圖中黑色陰影部分)的漸變就會不可見。

接下來,就是文字的繪制了。文字繪制在上述提到的文章中已經進行了詳細的講解,這里就不再贅述。代碼如下:

private void drawText(Canvas canvas) { 
 
   canvas.drawText(String.format(mPrecisionFormat, mValue), mCenterPoint.x, mValueOffset, mValuePaint); 
 
  
 
   if (mHint != null) { 
 
       canvas.drawText(mHint.toString(), mCenterPoint.x, mHintOffset, mHintPaint); 
 
   } 
 
  
 
   if (mUnit != null) { 
 
       canvas.drawText(mUnit.toString(), mCenterPoint.x, mUnitOffset, mUnitPaint); 
 
   } 
 
}  

最后,我們來實現進度條的動畫效果。這里我們使用 Android 的屬性動畫來實現進度更新。

private void startAnimator(float start, float end, long animTime) { 
 
   mAnimator = ValueAnimator.ofFloat(start, end); 
 
   mAnimator.setDuration(animTime); 
 
   mAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { 
 
       @Override 
 
       public void onAnimationUpdate(ValueAnimator animation) { 
 
           mPercent = (float) animation.getAnimatedValue(); 
 
           mValue = mPercent * mMaxValue; 
 
           if (BuildConfig.DEBUG) { 
 
               Log.d(TAG, "onAnimationUpdate: percent = " + mPercent 
 
                       + ";currentAngle = " + (mSweepAngle * mPercent) 
 
                       + ";value = " + mValue); 
 
           } 
 
           invalidate(); 
 
       } 
 
   }); 
 
   mAnimator.start(); 
 
}  

這里有兩個注意點:

1. 不要在 ValueAnimator.AnimatorUpdateListener 中輸出 Log,特別是動畫調用頻繁的情況下,因為輸出 Log 頻繁會生成大量 String 對象造成內存抖動,當然也可以使用 StringBuilder 來優化。

2. 關于 invalidate() 和 postInvalidate() 兩者最本質的前者只能在 UI 線程中使用,而后者可以在非 UI 線程中使用,其實 postInvalidate() 內部也是使用 Handler 實現的。

關于 Android 屬性動畫可以參考:

1. Android 屬性動畫(Property Animation) 完全解析 (上)

2. Android 屬性動畫(Property Animation) 完全解析 (下)

補充:同一個屬性如何支持顏色和顏色數組

考慮到圓弧設置單色和漸變的區別,即單色只需要提供一種色值,而漸變至少需要提供兩種色值。可以有以下幾種解決方案:

  1. 定義兩個屬性,漸變的優先級高于單色的。
  2. 定義一個 format 為 string 屬性,以 #FFFFFF|#000000 形式提供色值
  3. 定義一個 format 為 color|reference 的屬性,其中 reference 屬性指代漸變色的數組。

這里選用第三種方案,實現如下:

<!-- 圓形進度條 --> 
 
<declare-styleable name="CircleProgressBar"> 
 
    <!-- 圓弧顏色, --> 
 
    <attr name="arcColors" format="color|reference" /> 
 
</declare-styleable> 
 
  
 
<!-- colors.xml --> 
 
<color name="green">#00FF00</color> 
 
<color name="blue">#EE9A00</color> 
 
<color name="red">#EE0000</color> 
 
<!-- 漸變顏色數組 --> 
 
<integer-array name="gradient_arc_color"> 
 
   <item>@color/green</item> 
 
   <item>@color/blue</item> 
 
   <item>@color/red</item> 
 
</integer-array> 
 
  
 
<!-- 布局文件中使用 --> 
 
<!-- 使用漸變 --> 
 
<com.littlejie.circleprogress.DialProgress 
 
    android:id="@+id/dial_progress_bar" 
 
    android:layout_width="300dp" 
 
    android:layout_height="300dp" 
 
    app:arcColors="@array/gradient_arc_color" /> 
 
<!-- 使用單色 -->     
 
<com.littlejie.circleprogress.DialProgress 
 
    android:id="@+id/dial_progress_bar" 
 
    android:layout_width="300dp" 
 
    android:layout_height="300dp" 
 
    app:arcColors="@color/green" />  

代碼中讀取 xml 中配置:

int gradientArcColors = typedArray.getResourceId(R.styleable.CircleProgressBar_arcColors, 0); 
 
   if (gradientArcColors != 0) { 
 
       try { 
 
           int[] gradientColors = getResources().getIntArray(gradientArcColors); 
 
           if (gradientColors.length == 0) {//如果漸變色為數組為0,則嘗試以單色讀取色值 
 
               int color = getResources().getColor(gradientArcColors); 
 
               mGradientColors = new int[2]; 
 
               mGradientColors[0] = color; 
 
               mGradientColors[1] = color; 
 
           } else if (gradientColors.length == 1) {//如果漸變數組只有一種顏色,默認設為兩種相同顏色 
 
               mGradientColors = new int[2]; 
 
               mGradientColors[0] = gradientColors[0]; 
 
               mGradientColors[1] = gradientColors[0]; 
 
           } else { 
 
               mGradientColors = gradientColors; 
 
           } 
 
       } catch (Resources.NotFoundException e) { 
 
           throw new Resources.NotFoundException("the give resource not found."); 
 
       } 
 
   }  

帶刻度進度條

前面,詳細講了 CircleProgress 的繪制思路,接下來講 DialProgress。

實話說,DialProgress 與 CircleProgress 的實現極其相似,因為兩者之間其實就差了一個刻度,但考慮到擴展以及類職責的單一,所以將兩者分開。

這里主要講一下刻度的繪制。刻度繪制主要使用 Canvas 類的 save()、rotate()和restore() 方法,當然你也可以使用 translate() 方法對坐標系進行平移,方便計算。

/** 
 
* 用來保存Canvas的狀態。save之后,可以調用Canvas的平移、放縮、旋轉、錯切、裁剪等操作。 
 
*/ 
 
public void save() 
 
  
 
/** 
 
* 旋轉一定的角度繪制圖像 
 
* @param degrees 旋轉角度 
 
* @param x 旋轉中心點x軸坐標 
 
* @param y 旋轉中心點y軸坐標 
 
*/ 
 
public void rotate(float degrees, float x, float y) 
 
  
 
/** 
 
* 在當前的坐標上平移(x,y)個像素單位 
 
* 若dx <0 ,沿x軸向上平移; dx >0  沿x軸向下平移 
 
* 若dy <0 ,沿y軸向上平移; dy >0  沿y軸向下平移 
 
*/ 
 
public void translate(float dx, float dy) 
 
  
 
/** 
 
* 用來恢復Canvas之前保存的狀態。防止save后對Canvas執行的操作對后續的繪制有影響。 
 
*/ 
 
public void restore() 
 
 
private void drawDial(Canvas canvas) { 
 
   int total = (int) (mSweepAngle / mDialIntervalDegree); 
 
   canvas.save(); 
 
   canvas.rotate(mStartAngle, mCenterPoint.x, mCenterPoint.y); 
 
   for (int i = 0; i <= total; i++) { 
 
       canvas.drawLine(mCenterPoint.x + mRadius, mCenterPoint.y, mCenterPoint.x + mRadius + mArcWidth, mCenterPoint.y, mDialPaint); 
 
       canvas.rotate(mDialIntervalDegree, mCenterPoint.x, mCenterPoint.y); 
 
   } 
 
   canvas.restore(); 
 
}  

關于 Canvas 的畫布操作可以參考這篇文章:安卓自定義View進階-Canvas之畫布操作

水波紋效果的進度條

水波紋效果的進度條實現需要用到貝塞爾曲線,主要難點在于 繪制區域的計算 和 波浪效果 的實現,其余的邏輯跟上述兩種進度條相似。

這里使用了 Path 類,該類在 Android 2D 繪圖中是非常重要的,Path 不僅能夠繪制簡單圖形,也可以繪制這些比較復雜的圖形。也可以對多個路徑進行布爾操作,類似設置 Paint 的 setXfermode() ,具體使用可以參考這篇博客:安卓自定義View進階-Path基本操作。這里就不再贅述,有機會自己也會對 Android 自定義 View 的知識進行總結,不過,感覺應該了了無期。

繼續上示意圖,請叫我靈魂畫手~

圖中黑色的圓為我們要繪制的進度條圓,黑色的曲線為初始狀態的的波浪,該波浪使用貝塞爾曲線繪制,其中奇數的點為貝塞爾曲線的起始點,偶數的點為貝塞爾曲線的控制點。例如:1——>2——>3就為一條貝塞爾曲線,1 是起點,2 是控制點,3 是終點。從圖中可以看到波浪在園內圓外各一個(1—>5 和 5->9),通過對波浪在 x 軸上做平移,即圖中藍色實線,來實現波浪的動態效果,所以一個波浪的完整動畫效果需要有兩個波浪來實現。同理,通過控制 y 軸的偏移量,即圖中藍色虛線,可以實現波浪隨進度的上漲下降。

貝塞爾曲線上起始點和控制點的計算如下:

/** 
 
* 計算貝塞爾曲線上的起始點和控制點 
 
* @param waveWidth 一個完整波浪的寬度 
 
*/ 
 
private Point[] getPoint(float waveWidth) { 
 
   Point[] points = new Point[mAllPointCount]; 
 
   //第1個點特殊處理,即數組的中心 
 
   points[mHalfPointCount] = new Point((int) (mCenterPoint.x - mRadius), mCenterPoint.y); 
 
   //屏幕內的貝塞爾曲線點 
 
   for (int i = mHalfPointCount + 1; i < mAllPointCount; i += 4) { 
 
       float width = points[mHalfPointCount].x + waveWidth * (i / 4 - mWaveNum); 
 
       points[i] = new Point((int) (waveWidth / 4 + width), (int) (mCenterPoint.y - mWaveHeight)); 
 
       points[i + 1] = new Point((int) (waveWidth / 2 + width), mCenterPoint.y); 
 
       points[i + 2] = new Point((int) (waveWidth * 3 / 4 + width), (int) (mCenterPoint.y + mWaveHeight)); 
 
       points[i + 3] = new Point((int) (waveWidth + width), mCenterPoint.y); 
 
   } 
 
   //屏幕外的貝塞爾曲線點 
 
   for (int i = 0; i < mHalfPointCount; i++) { 
 
       int reverse = mAllPointCount - i - 1; 
 
       points[i] = new Point(points[mHalfPointCount].x - points[reverse].x, 
 
               points[mHalfPointCount].y * 2 - points[reverse].y); 
 
   } 
 
   return points; 
 
}  

以上,我們已經獲取到繪制貝塞爾曲線所需的路徑點。接下來,我們就需要來計算出繪制區域,即使用 Path 類。

紫色區域為貝塞爾曲線需要繪制的整體區域。

紅色區域為上圖紫色區域與圓的交集,也就是波浪要顯示的區域

代碼如下:

//該方法必須在 Android 19以上的版本才能使用(Path.op()) 
 
@TargetApi(Build.VERSION_CODES.KITKAT) 
 
private void drawWave(Canvas canvas, Paint paint, Point[] points, float waveOffset) { 
 
   mWaveLimitPath.reset(); 
 
   mWavePath.reset(); 
 
   //lockWave 用于判斷波浪是否隨進度條上漲下降 
 
   float height = lockWave ? 0 : mRadius - 2 * mRadius * mPercent; 
 
   //moveTo和lineTo繪制出水波區域矩形 
 
   mWavePath.moveTo(points[0].x + waveOffset, points[0].y + height); 
 
  
 
   for (int i = 1; i < mAllPointCount; i += 2) { 
 
       mWavePath.quadTo(points[i].x + waveOffset, points[i].y + height, 
 
               points[i + 1].x + waveOffset, points[i + 1].y + height); 
 
   } 
 
   mWavePath.lineTo(points[mAllPointCount - 1].x, points[mAllPointCount - 1].y + height); 
 
   //不管如何移動,波浪與圓路徑的交集底部永遠固定,否則會造成上移的時候底部為空的情況 
 
   mWavePath.lineTo(points[mAllPointCount - 1].x, mCenterPoint.y + mRadius); 
 
   mWavePath.lineTo(points[0].x, mCenterPoint.y + mRadius); 
 
   mWavePath.close(); 
 
   mWaveLimitPath.addCircle(mCenterPoint.x, mCenterPoint.y, mRadius, Path.Direction.CW); 
 
   //取該圓與波浪路徑的交集,形成波浪在圓內的效果 
 
   mWaveLimitPath.op(mWavePath, Path.Op.INTERSECT); 
 
   canvas.drawPath(mWaveLimitPath, paint);  

以上,就實現了水波動態的效果,當然,你也可以通過配置,來設定水波是否隨進度上漲下降。為了實現更好的效果,可以設置一個淺色的水波并支持設置水波的走向(R2L 和 L2R),通過設置淺色波浪和深色波浪動畫的時間,從而實現長江后浪推前浪的效果,恩,效果很自然的~自己腦補從右至左波浪的實現和貝塞爾點的計算。

對獲取坐標點的代碼進行優化:

/** 
 
* 從左往右或者從右往左獲取貝塞爾點 
 
* 
 
* @return 
 
*/ 
 
private Point[] getPoint(boolean isR2L, float waveWidth) { 
 
   Point[] points = new Point[mAllPointCount]; 
 
   //第1個點特殊處理,即數組的中點 
 
   points[mHalfPointCount] = new Point((int) (mCenterPoint.x + (isR2L ? mRadius : -mRadius)), mCenterPoint.y); 
 
   //屏幕內的貝塞爾曲線點 
 
   for (int i = mHalfPointCount + 1; i < mAllPointCount; i += 4) { 
 
       float width = points[mHalfPointCount].x + waveWidth * (i / 4 - mWaveNum); 
 
       points[i] = new Point((int) (waveWidth / 4 + width), (int) (mCenterPoint.y - mWaveHeight)); 
 
       points[i + 1] = new Point((int) (waveWidth / 2 + width), mCenterPoint.y); 
 
       points[i + 2] = new Point((int) (waveWidth * 3 / 4 + width), (int) (mCenterPoint.y + mWaveHeight)); 
 
       points[i + 3] = new Point((int) (waveWidth + width), mCenterPoint.y); 
 
   } 
 
   //屏幕外的貝塞爾曲線點 
 
   for (int i = 0; i < mHalfPointCount; i++) { 
 
       int reverse = mAllPointCount - i - 1; 
 
       points[i] = new Point((isR2L ? 2 : 1) * points[mHalfPointCount].x - points[reverse].x, 
 
               points[mHalfPointCount].y * 2 - points[reverse].y); 
 
   } 
 
   //對從右向左的貝塞爾點數組反序,方便后續處理 
 
   return isR2L ? MiscUtil.reverse(points) : points; 
 
}  

至此,自定義圓形進度條相關的思路已全部講述完成。代碼已全部上傳至 Git ,歡迎大家 Star 和 Fork,傳送門:CircleProgress。

https://github.com/MyLifeMyTravel/CircleProgress

 

 

 本文由用戶 rbinloacmy 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!