自定义View系列:打造一个显示密码等级的控件

0. 前言

最近项目不是特别紧张,有一点时间可以用来看点东西,于是找了下自定义View相关的文章来看看,毕竟也确实挺久没碰到了。和一基友交流了下,他最近也在看这方面,并且推荐了启舰的系列文章,大致都看了下,感觉讲的很细致,而且也挺全面,这里也推荐下,希望给大家在自定义View这块有所帮助。(传送门在文末会给出)

看完文章之后,得来一发才行呀,所谓“纸上得来终觉浅,绝知此事要躬行”嘛。想到了以前项目里有个显示密码等级的控件,那干脆就再来实现一下。

1. 需求

在注册界面,用户设置密码时,为了更好的交互体验,需要根据当前用户输入的密码的复杂程度,通过不同颜色的色块来实时的表示出密码有多强。好吧,说起来挺啰嗦,咱们直接看效果就知道了:
动图显示控件的效果
从效果图我们可以看出:

  • 控件总共有4个色块,分别为红、黄、蓝、绿,对应密码强度为风险、弱、中、强,而且色块后会有相应的强度描述,看起来好像是竖直方向居中的呢;
  • 用户输入的过程中,根据输入密码的复杂度,控件会实时更新成对应的状态;
  • 色块区域和文字之间貌似有一定间隙;

以上大致就是我们想要实现的东西了,接下来我们分析下具体实现。

2. 实现过程

2.1 自定义属性及解析

首先,一般自定义View都会涉及到自定义属性以及对应的解析取值操作,那么我们就来走一遍这个过程。我们定义一个属性,表示色块与文字之间的距离(当然其实定义文字尺寸貌似更好,这个就不用太过纠结了哈,都一样)。

1
2
3
4
5
6
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="PasswordLevelView">
<attr name="text_padding_level" format="dimension"/>
</declare-styleable>
</resources>

如你所见,属性定义就是这么简单。通过declare-styleable标签来定义了一个名为PasswordLevelView的属性集,通过attr标签来定义了一个名为text_padding_level的属性,值类型为dimension。

接下来我们要在xml布局里把刚定义好的属性用起来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<EditText
android:id="@+id/edt_password"
android:layout_width="200dp"
android:layout_height="wrap_content"
android:hint="please set password"
android:visibility="visible"/>
<com.example.jiangfei.passwordlevelview.widget.PasswordLevelView
android:id="@+id/pswd_level_view"
android:layout_width="160dp"
android:layout_height="16dp"
android:padding="4dp"
app:text_padding_level="8dp"/>
</LinearLayout>

好了,下面就该解析了:

1
2
3
4
5
6
7
public PasswordLevelView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
// 从xml读取自定义属性
TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.PasswordLevelView, defStyleAttr, 0);
mPaddingText = typedArray.getDimensionPixelSize(R.styleable.PasswordLevelView_text_padding_level, mPaddingText);
calculateTextWidth();
}

也没啥好说的,主要就是两个重要方法:obtainStyledAttributes与TypedArray.getDimensionPixelSize。如果定义的是其他数据类型的属性的话,通过相应的TypedArray.getXXX方法去拿就好了。

2.2 类实现

我们这里的需求比较简单,核心思路就是在合适的时机,在正确的位置,把色块和文字给画出来,这个操作在onDraw方法中通过Canvas就能实现,所以我们继承View就行。来看代码:

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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
/*
* 显示密码的强度等级的控件。
* Created by jiangfei on 2016/10/21.
*/
public class PasswordLevelView extends View {
// 文字尺寸
private float mTextWidth;
private float mTextHeight;
// 文字和图形的间距
private int mPaddingText = 0;
// 文字大小
private float mTextSize = 36F;
// 当前密级强度
private Level mCurLevel;
// 默认情况下的密级颜色
private int defaultColor = Color.argb(255, 220, 220, 220);
private Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
public enum Level {
DANGER("风险", Color.RED, 0), LOW("弱", Color.YELLOW, 1), MID("中", Color.BLUE, 2), STRONG("强", Color.GREEN, 3);
String mStrLevel;
int mLevelResColor;
int mIndex;
Level(String levelText, int levelResColor, int index) {
mStrLevel = levelText;
mLevelResColor = levelResColor;
mIndex = index;
}
}
public PasswordLevelView(Context context) {
this(context, null);
}
public PasswordLevelView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public PasswordLevelView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
// 从xml读取自定义属性
TypedArray typedArray =
context.getTheme().obtainStyledAttributes(attrs, R.styleable.PasswordLevelView, defStyleAttr, 0);
mPaddingText = typedArray.getDimensionPixelSize(R.styleable.PasswordLevelView_text_padding_level, mPaddingText);
calculateTextWidth();
}
private void calculateTextWidth() {
// 测量文字宽高,这里最多就2个字:风险
mPaint.setTextSize(mTextSize);
Rect rect = new Rect();
mPaint.getTextBounds(Level.DANGER.mStrLevel, 0, Level.DANGER.mStrLevel.length(), rect);
mTextWidth = rect.width();
mTextHeight = rect.height();
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int measuredWidth;
int measuredHeight;
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
measuredWidth = widthMode == MeasureSpec.EXACTLY ? widthSize : getMeasuredWidth();
measuredHeight = heightMode == MeasureSpec.EXACTLY ? heightSize : getMeasuredHeight();
// 处理padding设置异常时的控件高度
if (measuredHeight < getPaddingTop() + getPaddingBottom() + mTextHeight) {
measuredHeight = (int) (getPaddingTop() + getPaddingBottom() + mTextHeight);
}
// 固定套路,保存控件宽高值
setMeasuredDimension(measuredWidth, measuredHeight);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//计算密级色块区域的宽高
float levelAreaWidth =
getWidth() - getPaddingLeft() - getPaddingRight() - mPaddingText - mTextWidth;
int levelNum = Level.values().length;
float eachLevelWidth = levelAreaWidth / levelNum;
float eachLevelHeight = getHeight() - getPaddingTop() - getPaddingBottom();
int startIndexOfDefaultColor = mCurLevel == null ? 0 : mCurLevel.mIndex + 1;
float startRectLeft = getPaddingLeft();
// 画密级色块
for (int i = 0; i < levelNum; i++) {
if (i >= startIndexOfDefaultColor) {
mPaint.setColor(defaultColor);
} else {
mPaint.setColor(Level.values()[i].mLevelResColor);
}
canvas.drawRect(
startRectLeft,
getPaddingTop(),
startRectLeft + eachLevelWidth,
getPaddingTop() + eachLevelHeight,
mPaint);
startRectLeft += eachLevelWidth;
}
// 画色块后面的字
mPaint.setColor(Color.BLACK);
mPaint.setTextSize(mTextSize);
String strText = mCurLevel != null ? mCurLevel.mStrLevel : "";
// 计算text的baseline
Paint.FontMetrics fontMetrics = mPaint.getFontMetrics();
// baseline思路:先设为控件的水平中心线,再调整到文本区域的水平中心线上
// 注意:fontMetrics的top/bottom/ascent/descent属性值,是基于baseline为原点的,上方为负值,下方为正!
float baseLine =
getPaddingTop()
+ eachLevelHeight / 2
+ ((Math.abs(fontMetrics.ascent) + Math.abs(fontMetrics.descent)) / 2 - Math.abs(fontMetrics.descent));
canvas.drawText(
strText,
startRectLeft + mPaddingText,
baseLine,
mPaint);
// 最后,画一条水平中心线,看看文字的居中效果
// float centerVerticalY = getPaddingTop() + eachLevelHeight / 2F;
// canvas.drawLine(0F, centerVerticalY, getWidth(), centerVerticalY, mPaint);
}
/**
* 显示level对应等级的色块
*
* @param level 密码密级
*/
public void showLevel(Level level) {
mCurLevel = level;
invalidate();
}
}

代码不多,一点一点来过吧。
首先是定义了一个枚举Level,封装了对应的4个强度等级的信息。
接下来是3个构造方法,通过this调用,来执行参数最多的那个构造方法,这个是一般套路了,这里不再展开讲。
calculateTextWidth方法主要作用是计算强度文字的宽高尺寸,因为我们后面draw的时候需要各种计算尺寸。这里需要特别强调的是,在为文字测量宽高之前,需要先setTextSize,否则尺寸会不准确。这个也很好理解嘛,文字的大小不一样,当然所占用的宽高是不一样的。
接下来是onMeasure方法,也是一般的套路,通过MeasureSpec.getMode和MeasureSpec.getSize方法,来解析并调整控件的宽高,这里我们处理了高度值可能出现的异常情况。
然后是onDraw方法,在这里面我们draw了色块和文字。画色块的逻辑并不复杂,计算色块的宽高时,注意要把整体控件的padding值给考虑进去就行了。我们重点来看画文字的操作。
先来看一张图,不是本人原创哈:
FontMetric
在屏幕上展示的文字,不管尺寸颜色,都有上图中这几个重要的值:baseline, ascent, descent,他们分别表示的是哪一段长度,图上表示的很清除了,不再赘述。这些字段可以通过Paint.FontMetrics类来获取,而Paint.FontMetrics可以通过这个方法来取到:

1
Paint.FontMetrics fontMetrics = mPaint.getFontMetrics();

同样也要注意,在此之前需要先设置文字尺寸。
不知看到这里,大家有没有想过这些值的正负。我在调试的过程中截了个图:

尼玛啊,真是日了狗。。。API文档里说好的表示的是distance的呢?怎么会有正负值?事实上,我在画文字的过程中就掉这个坑里了,最后通过debug发现了这个坑,所以大家要特别注意,不要再掉里面啦。
好了,这几个字段说了这么多,到底有啥用呢?其实是为了drawText的时候计算baseline的坐标服务的。使用Canvas.drawText方法可以画文字,其中有两个参数就是文字的baseline坐标。所以为了把文字draw到色块横向对应的中心线上,我们需要计算出文字的baseline坐标:

1
2
3
// baseline思路:先设为控件的水平中心线,再调整到文本区域的水平中心线上
// 注意:fontMetrics的top/bottom/ascent/descent属性值,是基于baseline为原点的,上方为负值,下方为正!
float baseLine = getPaddingTop() + eachLevelHeight / 2 + ((Math.abs(fontMetrics.ascent) + Math.abs(fontMetrics.descent)) / 2 - Math.abs(fontMetrics.descent));

OK,比较难的baseline坐标值就搞定了。到此,核心的东西就都介绍完了。

3. 总结

本文实现了一个简单的密码等级展示效果,目的是回顾一下自定义View的过程。在我看来,自定义View的关键是要理解并掌握Canvas,Paint,Matrix这几个核心的绘制相关类,还有就是各种动画。掌握好这些,要实现一些复杂炫酷的效果就不是困难的事情啦。

最后,我是demo下载地址

4. 参考资料

1.Android自定义控件三部曲文章索引

2.Android字符串进阶之三:字体属性及测量(FontMetrics)

很惭愧<br><br>只做了一点微小的工作<br>谢谢大家