自定义View之IndexView进度条A

最近项目有个找回密码的状态进度条显示需求:

项目需求

以前遇到这种情况,基本上都是直接让美工帮忙实现的,你懂的,现在想研究一下自定义View,然后就开始挖坑填坑了,不过总体感觉这个UI其实实现原理比较简单,最后还是很不愉快地实现了,并且可以动态扩展,支持动态配置步骤的数量,看一下实现的效果

最终实现的效果:

效果图

关于自定义View的知识这里就不再多说了,这个View没有子控件,所以选择集成系统的View,整个界面其实看上去比较简单,就是画圆,画线,画文字,只是想分享一下自己在实现的过程中遇到的一些问题,虽然界面上有好多需求。

实现步骤

1.自定义属性

因为需要绘制的控件比较多,所以涉及到的颜色跟间距需要进行属性声明,然后由于考虑到控件的扩展性,以后可能会涉及到更多的步骤,所以步骤的数量也是动态配置的,并没有写死,只需要配置好相应的数据源,就可以动态地生成相应的控件,属性的命名比较规范,看名字就能知道:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!--自定义属性集合:ProgressView-->
<declare-styleable name="PasswordView">
<attr name="circle_color_checked" format="color"/>
<attr name="circle_color_unchecked" format="color"/>
<attr name="number_color_checked" format="color"/>
<attr name="number_color_unchecked" format="color"/>
<attr name="line_color" format="color"/>
<attr name="text_color" format="color"/>
<attr name="text_size" format="dimension"/>
<attr name="text_padding" format="dimension"/>
<attr name="circle_radius" format="dimension"/>
<attr name="edge_line_width" format="dimension"/>
<attr name="center_line_width" format="dimension"/>
<attr name="topName" format="reference"/>
<attr name="bottomName" format="reference"/>
<attr name="checkedNumber" format="integer"/>
</declare-styleable>

需要说明的是bottomName和topName这两个属性,对应的是数组id,也就是上面的数字数组跟下面的文字数组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="topNames">
<item>1</item>
<item>2</item>
<item>3</item>
</string-array>

<string-array name="bottomNames">
<item>验证手机</item>
<item>重设密码</item>
<item>重设成功</item>
</string-array>
</resources>

然后是在布局文件中引用

1
2
app:topName="@array/topNames"
app:bottomName="@array/bottomNames"

2.在代码中获取相应的属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.PasswordView);
mCircleColorChecked = typedArray.getColor(R.styleable.PasswordView_circle_color_checked, Color.RED);
mCircleColorUnchecked = typedArray.getColor(R.styleable.PasswordView_circle_color_unchecked, Color.RED);
mNumberColorChecked = typedArray.getColor(R.styleable.PasswordView_number_color_checked, Color.RED);
mNumberColorUnchecked = typedArray.getColor(R.styleable.PasswordView_number_color_unchecked, Color.RED);
mTextPadding = toPx(typedArray.getDimension(R.styleable.PasswordView_text_padding, toPx(12.0f)));
mLineColor = typedArray.getColor(R.styleable.PasswordView_line_color, Color.RED);
mTextColor = typedArray.getColor(R.styleable.PasswordView_text_color, Color.RED);
mCircleRadius = toPx(typedArray.getDimension(R.styleable.PasswordView_circle_radius, 0));
mTextSize = toPx(typedArray.getDimension(R.styleable.PasswordView_text_size, 0));
mEdgeLineWidth = toPx(typedArray.getDimension(R.styleable.PasswordView_edge_line_width, 0));
mCenterLineWidth = toPx(typedArray.getDimension(R.styleable.PasswordView_center_line_width, 0));
mCheckedNumber = typedArray.getInteger(R.styleable.PasswordView_checkedNumber, 0);
int topNamesId = typedArray.getResourceId(R.styleable.PasswordView_topName, 0);
if (topNamesId != 0)
mTopNames = getResources().getStringArray(topNamesId);
int bottomNamesId = typedArray.getResourceId(R.styleable.PasswordView_bottomName, 0);
if (bottomNamesId != 0)
mBottomNames = getResources().getStringArray(bottomNamesId);
childNumbers = mBottomNames.length;
typedArray.recycle();

3.onMeasure

在此方法中获取View的宽高,这里有几点要说明一下:

1
2
3
4
5
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
setMeasuredDimension(measuredWidth(widthMeasureSpec), measuredHeight(heightMeasureSpec));
}

宽度测量

  • 当width为match_parent或者具体的长度

此时的测量模式为EXACTLY,View的宽度为父容器的宽度或者指定的宽度,这个时候需要计算的就是mCenterLineWidth,也就是中间的那几条线段的宽度,这个宽度用来定位中间的圆的横坐标,它们的宽度就是整个View的宽度减去已知控件的长度之后除以中间线段条数的平均值。

  • 当width为wrap_content的时候

此时的测量模式为AT_MOST,View的宽度就是所有已知控件的长度之和,此时必须指定中间线的宽度,不然没有办法计算View的总宽度,具体计算方式见下面代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//测量宽度
private int measuredWidth(int widthMeasureSpec) {
int width = MeasureSpec.getSize(widthMeasureSpec);
int mode = MeasureSpec.getMode(widthMeasureSpec);
int result = 0;
switch (mode) {
case MeasureSpec.EXACTLY://width为match或者具体的长度
result = width;
//通过均分来计算中间分发现的宽度
mCenterLineWidth = (result - getPaddingLeft() - getPaddingRight() - 2 * mEdgeLineWidth - 2 * mCircleRadius * childNumbers) / (childNumbers - 1);
break;
case MeasureSpec.AT_MOST://width为wrap
//通过自定义属性来计算测量的宽度
int realWidth = getPaddingLeft() + getPaddingLeft() + 2 * mEdgeLineWidth + 2 * mCircleRadius * childNumbers + mCenterLineWidth * (childNumbers - 1);
result = Math.min(realWidth, width);
break;
}
return result;
}

高度测量

  • 当height为match_parent或者具体的长度

此时的测量模式为EXACTLY,View的高度为父容器的高度或者指定的高度

  • 当width为wrap_content的时候

此时的测量模式为AT_MOST,View的高度就是所有已知控件的长度之和,此时必须指定mTextPadding的高度,不然没有办法计算View的总高度,具体计算方式见下面代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//测量高度
private int measuredHeight(int heightMeasureSpec) {
int height = MeasureSpec.getSize(heightMeasureSpec);
int mode = MeasureSpec.getMode(heightMeasureSpec);
int result = 0;
switch (mode) {
case MeasureSpec.EXACTLY://height为match或者具体的长度
result = height;
break;
case MeasureSpec.AT_MOST://height为wrap_content
int realHeight = getPaddingTop() + getPaddingBottom() + 2 * mCircleRadius + mTextPadding + mTextHeight;
result = Math.min(height, realHeight);
break;
}
return result;
}

4.onDraw

这里有一点需要注意的,就是要先画线,再画圆,而且都需要是实心圆,这样线只需要画一次,因为圆可以把线给盖住,左端点到右端点,不然需要绘制好几段,特别麻烦,所以要注意策略的选择,由于步骤的数量的不确定性导致圆心以及文字的起始坐标都需要动态计算,动态绘制,这里需要简单计算一下,也不是很麻烦,

  • float cx = getPaddingLeft() + mEdgeLineWidth + mCircleRadius + i (mCenterLineWidth + 2 mCircleRadius);//圆心横坐标
  • float cy = getPaddingTop() + mCircleRadius;//圆心纵坐标

圆心确定了,周围的控件也都比较好确定了,坐标订好了,就直接进行绘制了,感觉自定义控件就是各种计算padding,margin之类,真正需要绘制的并不是很复杂,当然可能我这个自定义控件比较简单,只有UI效果,没有交互逻辑,基本上到这儿已经完成了,放一下绘制的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
float startX = getPaddingLeft();
float endX = getMeasuredWidth() - getPaddingRight();
float startY = getPaddingTop() + mCircleRadius;
canvas.drawLine(startX,startY,endX,startY,mPaint);
//画圆及圆中的数字
for (int i = 0; i < mTopNames.length; i++) {
float cx = getPaddingLeft() + mEdgeLineWidth + mCircleRadius + i * (mCenterLineWidth + 2 * mCircleRadius);//圆心横坐标
float cy = getPaddingTop() + mCircleRadius;//圆心纵坐标
float baseNumberX = cx - mNumberWidth / 2;//数字文本框的左上定点
float baseNumberY = cy + mNumberHeight / 2 - mFontMetrics.bottom;//文字文本框的基线
float baseTextX = cx - mTextWidth / 2;//文字文本框的左上定点
float baseTextY = getHeight() - getPaddingBottom() - mFontMetrics.bottom;//文字文本框的基线
if (i == mCheckedNumber) {
//画实心圆
mPaint.setColor(mCircleColorChecked);
mPaint.setStyle(Paint.Style.FILL);
canvas.drawCircle(cx, cy, mCircleRadius, mPaint);
//描边
mPaint.setColor(mCircleColorChecked);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeWidth(2);
canvas.drawCircle(cx, cy, mCircleRadius, mPaint);
//画数字
mPaint.setStyle(Paint.Style.FILL);
mPaint.setColor(mNumberColorChecked);
canvas.drawText(mTopNames[i], baseNumberX, baseNumberY, mPaint);
} else {
//画空心圆
mPaint.setColor(mCircleColorUnchecked);
mPaint.setStyle(Paint.Style.FILL);
mPaint.setStrokeWidth(2);
canvas.drawCircle(cx, cy, mCircleRadius, mPaint);
//描边
mPaint.setColor(mNumberColorUnchecked);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeWidth(2);
canvas.drawCircle(cx, cy, mCircleRadius, mPaint);
//画数字
mPaint.setStyle(Paint.Style.FILL);
mPaint.setColor(mNumberColorUnchecked);
canvas.drawText(mTopNames[i], baseNumberX, baseNumberY, mPaint);

}
//画文字
mPaint.setColor(mTextColor);
mPaint.setStyle(Paint.Style.FILL);
canvas.drawText(mBottomNames[i], baseTextX, baseTextY, mPaint);
mPaint.setColor(mLineColor);
mPaint.setStyle(Paint.Style.FILL);
mPaint.setStrokeWidth(3);

}
}

5.使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<com.fatchao.passwordview.PasswordView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/white"
android:paddingBottom="@dimen/dp_10"
android:paddingLeft="@dimen/dp_10"
android:paddingRight="@dimen/dp_10"
android:paddingTop="@dimen/dp_15"
app:bottomName="@array/bottomNames"
app:checkedNumber="1"
app:circle_color_checked="@color/grey"
app:circle_color_unchecked="@color/white"
app:circle_radius="15dp"
app:edge_line_width="30dp"
app:line_color="@color/grey"
app:number_color_checked="@color/white"
app:number_color_unchecked="@color/black"
app:text_color="@color/grey"
app:text_padding="@dimen/dp_12"
app:text_size="14sp"
app:topName="@array/topNames"
/>

点击下载Demo