0. 前言
最近项目不是特别紧张,有一点时间可以用来看点东西,于是找了下自定义View相关的文章来看看,毕竟也确实挺久没碰到了。和一基友交流了下,他最近也在看这方面,并且推荐了启舰的系列文章,大致都看了下,感觉讲的很细致,而且也挺全面,这里也推荐下,希望给大家在自定义View这块有所帮助。(传送门在文末会给出)
看完文章之后,得来一发才行呀,所谓“纸上得来终觉浅,绝知此事要躬行”嘛。想到了以前项目里有个显示密码等级的控件,那干脆就再来实现一下。
1. 需求
在注册界面,用户设置密码时,为了更好的交互体验,需要根据当前用户输入的密码的复杂程度,通过不同颜色的色块来实时的表示出密码有多强。好吧,说起来挺啰嗦,咱们直接看效果就知道了:
从效果图我们可以看出:
- 控件总共有4个色块,分别为红、黄、蓝、绿,对应密码强度为风险、弱、中、强,而且色块后会有相应的强度描述,看起来好像是竖直方向居中的呢;
- 用户输入的过程中,根据输入密码的复杂度,控件会实时更新成对应的状态;
- 色块区域和文字之间貌似有一定间隙;
以上大致就是我们想要实现的东西了,接下来我们分析下具体实现。
2. 实现过程
2.1 自定义属性及解析
首先,一般自定义View都会涉及到自定义属性以及对应的解析取值操作,那么我们就来走一遍这个过程。我们定义一个属性,表示色块与文字之间的距离(当然其实定义文字尺寸貌似更好,这个就不用太过纠结了哈,都一样)。
如你所见,属性定义就是这么简单。通过declare-styleable标签来定义了一个名为PasswordLevelView的属性集,通过attr标签来定义了一个名为text_padding_level的属性,值类型为dimension。
接下来我们要在xml布局里把刚定义好的属性用起来:
好了,下面就该解析了:
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就行。来看代码:
/*
* 显示密码的强度等级的控件。
* 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值给考虑进去就行了。我们重点来看画文字的操作。
先来看一张图,不是本人原创哈:
在屏幕上展示的文字,不管尺寸颜色,都有上图中这几个重要的值:baseline, ascent, descent,他们分别表示的是哪一段长度,图上表示的很清除了,不再赘述。这些字段可以通过Paint.FontMetrics类来获取,而Paint.FontMetrics可以通过这个方法来取到:
Paint.FontMetrics fontMetrics = mPaint.getFontMetrics();
同样也要注意,在此之前需要先设置文字尺寸。
不知看到这里,大家有没有想过这些值的正负。我在调试的过程中截了个图:
尼玛啊,真是日了狗。。。API文档里说好的表示的是distance的呢?怎么会有正负值?事实上,我在画文字的过程中就掉这个坑里了,最后通过debug发现了这个坑,所以大家要特别注意,不要再掉里面啦。
好了,这几个字段说了这么多,到底有啥用呢?其实是为了drawText的时候计算baseline的坐标服务的。使用Canvas.drawText方法可以画文字,其中有两个参数就是文字的baseline坐标。所以为了把文字draw到色块横向对应的中心线上,我们需要计算出文字的baseline坐标:
// 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下载地址