自定义 View 实践之:打造一个显示密码强度等级的控件

1,949 阅读7分钟

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值给考虑进去就行了。我们重点来看画文字的操作。
先来看一张图,不是本人原创哈:
FontMetric
在屏幕上展示的文字,不管尺寸颜色,都有上图中这几个重要的值: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下载地址

4. 参考资料

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

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