最近作项目碰到一个这样的一个需求:须要一个环形的进度条表示一个下载请求的进度加载。 同时要以各类不一样的图标展示其下载过程当中的各个状态:等待、下载中、暂停、错误、完成。java
具体状态对应图标见下图: android
以上图标来自www.iconfont.cn/。git
考虑到其状态多达 5 种之多。用已有的控件组合显示,而后判断状态来控制各图标的显示不太合适。 借此机会,简单的撸一个这样的一个自定义控件:CircleProgressBar 来温习下自定义控件的知识。github
直接拷贝 CircleProgressBar 使用:CircleProgressBar.javacanvas
首先须要的基础知识,你须要了解关于安卓自定义控件的基本原理、控件的绘制过程。 推荐看下官方的相关文档 Custom View Components。注意:文档为英文文档,有墙。markdown
简单总结下见下表: app
搞清楚上面的基础以后就正式开始自定义控件。若是尚未看过上述文档也能够跟着我把下面的步奏写一遍。ide
通常自定义 View 都是继承自 android.view.View。不过既然咱们自定义的是 ProgressBar,就不必重头开始了,直接继承自 android.widget.ProgressBar 。 这样 setProgress(int progress); 这些基础方法就不必再定义了。So,给个人控件取名为 CircleProgressBar extends ProgressBar
。oop
观察上述几个图标,除了下载中状态有进度加载,其形态有所改变外,其他状态均为一个静态图片。如今只用搞定下载中状态的圆环进度和绘制中间的两条竖线便可。布局
咱们在使用 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() 方法来回收。
上述方法只能经过 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 就是为了可以让自定义的控件可以根据各类不一样的状况以合适的宽高去展现。 具体使用到的方法为 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。它们的含义是:
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 的宽度计算出具体的值。
接下来就是绘制效果了。
如开始所述:观察上述几个图标,除了下载中状态有进度加载,其形态有所改变外,其他状态均为一个静态图片。绘制其他状态静态图片可使用: 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:
演示 apk 下载: blog.chengww.com/files/Circl…