Android自定义View实现6位数字密码控件

记得遇到过见到这样需求:用户输入密码或者验证码的时候,对输入控件做样式格式控制,如下图所示:


EditText作为一个控件允许我们进行文字输入,遇到这种特殊的需求EditText就满足不了我们了,我们可以自定义View来实现以上功能。

分析一下需求,主要有这些功能点:

  1. 对于输入位数的控制。
  2. 用颜色和光标区分输入状态和为未输入状态。
  3. 输入框样式的控制:矩形、圆形、下划线。
  4. 输入内容的控制:只能输入数字,显示上分为密码文本(* 和 圆点)和输入数据文本。

自定义这样一个View并不是很难,主要是考虑对坐标的计算和对键盘的监听以及对输入状态的控制。但是今天所写的不是纯自定义View绘制所实现的方法,而是用自定义Layout+自定义View实现的方法,也是我第一次实现这个需求的思考过程。

在第一次见到这个需求时,我心里想的第一个思路不是用纯绘制view去实现,而是把控件分解,用面向对象的思路去实现。

在编程中,对象就是一系列的属性集合封装类。而在现实生活中也可以看到这样的例子,比如一个士兵就是一个对象,会报数、跑动、跳跃等等技能,而这些技能就是士兵对象所拥有的属性。我们作为士兵的长官,可以操作士兵的属性,也就是对士兵下命令。

比如上图的第一个控件,我作为士兵的长官,命令6个士兵在我面前站成一排。当我打开键盘输入数字时就像是在对士兵下命令:从左往右报数。光标的闪烁就像是士兵在报数一样,于是可以看到光标从左往右移动,输入数字后输入框内会多一个点,就像报完数的士兵会马上闭上嘴一样,当所有的士兵都闭上的自己的嘴时,说明报数已经完成,而控件光标不再在闪烁时,说明我输入已经完成。

在这里我们就把单个输入框对象当成一个士兵,它拥有输入状态、光标闪动、背景图案、显示内容等属性。我作为它们的长官,对它们的属性行为进行控制,不同的图案背景(矩形、圆形、下划线)就是我命令它们穿上不同的服装,好让我进行区分。

作为一个长官,手底下是有自己的士兵的,而在这里我们怎么拥有自己的士兵呢?我们第一步先来实现这个单输入框对象,拥有自己的士兵。

我们先定义一个类PassWordView继承自View:

public class PassWordView extends View

主要关注两个方法onMeasure()和onDraw()。

onMeasure方法,实现对View的测量

 @Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    int sizeWidth = MeasureSpec.getSize(widthMeasureSpec);
    int modeWidth = MeasureSpec.getMode(widthMeasureSpec);
    int sizeHeight = MeasureSpec.getSize(heightMeasureSpec);
    int modeHeight = MeasureSpec.getMode(heightMeasureSpec);

    int width = 0;
    int height = 0;

    if (modeWidth == MeasureSpec.EXACTLY) {//如果是精确测量 则直接返回值
        width = sizeWidth;
    } else {//指定宽度的大小
        width = DensityUtil.dip2px(mContext, mWidth);
        if (modeWidth == MeasureSpec.AT_MOST) {//如果是最大值模式  取当中的小值  防止超出父类控件的最大值
            width = Math.min(width, sizeWidth);
        }
    }

    if (modeHeight == MeasureSpec.EXACTLY) {//如果是精确测量 则直接返回值
        height = sizeHeight;
    } else {//指定高度的大小
        height = DensityUtil.dip2px(mContext, mheight);
  
        if (modeHeight == MeasureSpec.AT_MOST) {//如果是最大值模式  取当中的小值  防止超出父类控件的最大值
            height = Math.min(height, sizeHeight);
        }
    }
    setMeasuredDimension(width, height);
}

onDraw方法实现对View的绘制,在这个方法里我们主要绘制了三样东西:输入框、提醒线和输入文本。

protected void onDraw(Canvas canvas) {

    drawInputBox(canvas);    //绘制输入框

    drawRemindLine(canvas);  //绘制提醒线

    drawInputTextOrPicture(canvas);         //绘制输入文本或密码图案
}

绘制输入框drawInputBox()方法,我们用变量 isInputState 来区别输入状态,用 mBoxDrawType 来区别要绘制图形是圆形、矩形或是横线。

private void drawInputBox(Canvas canvas) {
    if (isInputState) {                  //是否是输入状态  输入状态和未输入状态颜色区分
        mPaint.setColor(ContextCompat.getColor(mContext, mInputStateBoxColor));
    } else {
        mPaint.setColor(ContextCompat.getColor(mContext, mNoInputStateBoxColor));
    }

    mPaint.setStyle(Paint.Style.STROKE);//空心

    switch (mBoxDrawType) {
        case 1:         //绘制圆形
            canvas.drawCircle(getMeasuredWidth() / 2, getMeasuredHeight() / 2, getMeasuredWidth() / 2 - 5, mPaint);
            break;
        case 2:        //绘制横线
            canvas.drawLine(0, getMeasuredHeight(), getMeasuredWidth(), getMeasuredHeight(), mPaint);
            break;

        default:     // 绘制矩形 默认
            RectF rect = new RectF(0, 0, getMeasuredWidth(), getMeasuredHeight());
            canvas.drawRoundRect(rect, 6, 6, mPaint);
    }

}

drawRemindLine()用于绘制输入提示线

/**
 * 绘制提示线
 *
 * @param canvas
 */
private void drawRemindLine(Canvas canvas) {
    if (mDrawRemindLineState && isShowRemindLine) {  // mDrawRemindLineState 控制闪烁情况  //isShowRemindLine 是否绘制提示线
        int line_height = getMeasuredWidth() / 2 - 10;

        line_height = line_height < 0 ? getMeasuredWidth() / 2 : line_height;

        mPaint.setStyle(Paint.Style.FILL);//实心
        mPaint.setColor(ContextCompat.getColor(mContext, mRemindLineColor));
        canvas.drawLine(getMeasuredWidth() / 2, getMeasuredHeight() / 2 - line_height / 2, getMeasuredWidth() / 2, getMeasuredHeight() / 2 + line_height / 2, mPaint);
    }
}

drawInputTextOrPicture()方法用变量mShowPassType来区别执行输入操作后界面上显示圆心、* 或显示原文。

private void drawInputTextOrPicture(Canvas canvas) {
    if (isDrawText) {

        mPaint.setColor(ContextCompat.getColor(mContext, mInputStateTextColor));
        mPaint.setStyle(Paint.Style.FILL);//实心
        switch (mShowPassType) {
            case 0:                 //绘制圆心
                canvas.drawCircle(getMeasuredWidth() / 2, getMeasuredHeight() / 2, 12, mPaint);
                break;
            case 1:              //绘制*
                mPaint.setTextSize(getMeasuredWidth() / 2 + 10);
                float stringWidth = mPaint.measureText("*");

                float baseY = (getMeasuredHeight() / 2 - ((mPaint.descent() + mPaint.ascent()) / 2)) + stringWidth / 3;  //实现y轴居中方法
                float baseX = getMeasuredWidth() / 2 - stringWidth / 2;  //实现X轴居中方法
                canvas.drawText("*", baseX, baseY, mPaint); //文字
                break;
            case 2:              //绘制输入数据
                mPaint.setTextSize(DensityUtil.sp2px(mContext, mDrawTxtSize));//绘制字体大小
                float stringWidth2 = mPaint.measureText(mPassText);

                float baseY2 = (getMeasuredHeight() / 2 - ((mPaint.descent() + mPaint.ascent()) / 2)) + stringWidth2 / 5;  //实现y轴居中方法
                float baseX2 = getMeasuredWidth() / 2 - stringWidth2 / 2;  //实现X轴居中方法
                canvas.drawText(mPassText, baseX2, baseY2, mPaint); //文字
                break;
        }
    }
}

下面是一些控制状态的变量,这些变量的值我们会通过PassWordLayout进行控制。

private boolean isDrawText;//是否绘制文本

private boolean isInputState = false;//是否输入状态

private boolean mDrawRemindLineState;  //竖线状态控制  true 显示

private int mInputStateBoxColor;  //输入状态下框颜色
private int mNoInputStateBoxColor;//未输入状态下框颜色

private int mRemindLineColor;  //提示输入线颜色
private int mInputStateTextColor;  //输入后文字图案提示颜色


private int mBoxDrawType = 0;//盒子图画类型 0 矩形 2圆形 3横线
private int mShowPassType = 0;// 0 提示图案为实心圆 1提示图案为*

private boolean isShowRemindLine = true;// true 显示提示光标 默认显示

接下来创建一个类PassWordLayout继承自LinearLayout,对PassWordView进行管理。

public class PassWordLayout extends LinearLayout

然后根据子PassWordView变量抽取一些属性,用于在使用时进行状态上的控制

    <!--密码输入layout-->
    <declare-styleable name="PassWordLayoutStyle">
        <attr name="box_input_color" format="reference"></attr>//输入框输入状态颜色
        <attr name="box_no_input_color" format="reference"></attr>//输入框未输入状态颜色
        <attr name="input_line_color" format="reference"></attr>//输入线颜色
        <attr name="text_input_color" format="reference"></attr>//文本颜色
        <attr name="interval_width" format="integer"></attr>//间隔
        <attr name="item_width" format="integer"></attr>//子View宽
        <attr name="item_height" format="integer"></attr>//子View高
        <attr name="draw_txt_size" format="integer"></attr>//文本大小
        <attr name="draw_box_line_size" format="integer"></attr>//输入线条大小
        <attr name="is_show_input_line" format="boolean"></attr>//是否显示输入线
        <attr name="pass_tips_type" >       //密码输入显示内容
            <flag name="stars" value="0" />
            <flag name="circle" value="1" />
            <flag name="text" value="2" />
        </attr>
        <attr name="box_draw_type" >              //密码框形状
            <flag name="rect" value="0" />
            <flag name="circle" value="1" />
            <flag name="line" value="2" />
        </attr>
        <attr name="pass_leng" >       //密码长度 只提供 4 6 8
            <flag name="four" value="4" />
            <flag name="six" value="6" />
            <flag name="eight" value="8" />
        </attr>
    </declare-styleable>

初始化的时候获取属性

     TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.PassWordLayoutStyle);

        int inputColor = ta.getResourceId(R.styleable.PassWordLayoutStyle_box_input_color, R.color.pass_view_rect_input);
        int noinputColor = ta.getResourceId(R.styleable.PassWordLayoutStyle_box_no_input_color, R.color.color_common_gray);
        int lineColor = ta.getResourceId(R.styleable.PassWordLayoutStyle_input_line_color, R.color.pass_view_rect_input);
        int txtInputColor = ta.getResourceId(R.styleable.PassWordLayoutStyle_text_input_color, R.color.pass_view_rect_input);
        int drawType= ta.getInt(R.styleable.PassWordLayoutStyle_box_draw_type, 0);
        int interval = ta.getInt(R.styleable.PassWordLayoutStyle_interval_width, 4);
        maxLength = ta.getInt(R.styleable.PassWordLayoutStyle_pass_leng, 6);
        int itemWidth = ta.getInt(R.styleable.PassWordLayoutStyle_item_width, 40);
        int itemHeight = ta.getInt(R.styleable.PassWordLayoutStyle_item_height, 40);
        int showPassType = ta.getInt(R.styleable.PassWordLayoutStyle_pass_inputed_type, 0);
        int txtSize = ta.getInt(R.styleable.PassWordLayoutStyle_draw_txt_size, 18);
        int boxLineSize = ta.getInt(R.styleable.PassWordLayoutStyle_draw_box_line_size, 4);
        mIsShowInputLine = ta.getBoolean(R.styleable.PassWordLayoutStyle_is_show_input_line, true);
        ta.recycle();

在PassWordLayout 中我们主要做这几件事。

  1. 确定个数。
  2. 输入法监听。
  3. 焦点处理。
  4. 状态回调。
  5. 输入状态保存。
  1. 确定个数。
    在ViewGroup中有一个addView()方法用于添加子View。在这里用变量maxLength来控制子View的个数,然后遍历添加我们的PassWordView并控制属性。

maxLength的值通过 pass_leng 属性获取,目前提供的密码长度是4、6、8,这个控件在开始时就考虑了其适用范围,所以在密码长度过长时显示上没有做处理。

    for (int i = 0; i < maxLength; i++) {
            PassWordView passWordView = new PassWordView(context);
            LayoutParams params = new LayoutParams(DensityUtil.dip2px(mContext, itemWidth), DensityUtil.dip2px(mContext, itemHeight));
            if (i != 0) {                                       //第一个子View不添加边距
                params.leftMargin = DensityUtil.dip2px(context, DensityUtil.dip2px(mContext, interval));
            }

            passWordView.setInputStateColor(inputColor);
            passWordView.setNoinputColor(noinputColor);
            passWordView.setInputStateTextColor(txtInputColor);
            passWordView.setRemindLineColor(lineColor);
            passWordView.setmBoxDrawType(mDrawType);
            passWordView.setmShowPassType(showPassType);
            passWordView.setmDrawTxtSize(txtSize);
            passWordView.setmDrawBoxLineSize(boxLineSize);
            passWordView.setmIsShowRemindLine(mIsShowInputLine);

            addView(passWordView, params);
        }

2.调用输入法。

//设置点击时弹出输入法
     setOnClickListener(new OnClickListener() {
         @Override
         public void onClick(View view) {
             setFocusable(true);
             setFocusableInTouchMode(true);
             requestFocus();
             InputMethodManager imm = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
             imm.showSoftInput(PassWordLayout.this, InputMethodManager.SHOW_IMPLICIT);
         }
     });
     this.setOnKeyListener(new MyKeyListener());//按键监听

监听键盘,当用户按下数字按钮的的时候,新增一个密码,当用户点击删除的时候删除密码。

  /**
     * 按键监听器
     */
    class MyKeyListener implements OnKeyListener {
        @Override
        public boolean onKey(View v, int keyCode, KeyEvent event) {
            if (event.getAction() == KeyEvent.ACTION_DOWN) {
                if (event.isShiftPressed()) {//处理*#等键
                    return false;
                }
                if (keyCode >= KeyEvent.KEYCODE_0 && keyCode <= KeyEvent.KEYCODE_9) {//处理数字
                    addPwd(keyCode - 7 + "");              //点击添加密码
                    return true;
                }

                if (keyCode == KeyEvent.KEYCODE_DEL) {       //点击删除
                    removePwd();
                    return true;
                }

                InputMethodManager imm = (InputMethodManager) mContext.getSystemService(Context.INPUT_METHOD_SERVICE);
                imm.hideSoftInputFromWindow(getWindowToken(), InputMethodManager.HIDE_NOT_ALWAYS);
                return true;
            }
            return false;
        }//onKey
    }

重写onCreateInputConnection方法进行键盘处理


    @Override
    public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
        outAttrs.inputType = InputType.TYPE_CLASS_NUMBER;          //显示数字键盘
        outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI;
        return new ZanyInputConnection(this, false);
    }


    private class ZanyInputConnection extends BaseInputConnection {

        @Override
        public boolean commitText(CharSequence txt, int newCursorPosition) {
            return super.commitText(txt, newCursorPosition);
        }

        public ZanyInputConnection(View targetView, boolean fullEditor) {
            super(targetView, fullEditor);
        }

        @Override
        public boolean sendKeyEvent(KeyEvent event) {
            return super.sendKeyEvent(event);
        }


        @Override
        public boolean deleteSurroundingText(int beforeLength, int afterLength) {
            if (beforeLength == 1 && afterLength == 0) {
                return sendKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL)) && sendKeyEvent(new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DEL));
            }

            return super.deleteSurroundingText(beforeLength, afterLength);
        }
    }
  1. 焦点处理。
    变量inputIndex是储存了用户输入下标。
    当获得焦点时根据变量inputIndex取出相应的子View,设为闪烁状态,失去焦点时取消闪烁状态。

        setOnFocusChangeListener(new OnFocusChangeListener() {
            @Override
            public void onFocusChange(View view, boolean b) {
                if (b) {
                    PassWordView passWordView = (PassWordView) getChildAt(inputIndex);
                    if (passWordView != null) {
                        passWordView.setmIsShowRemindLine(mIsShowInputLine);
                        passWordView.startInputState();
                    }
                } else {
                    PassWordView passWordView = (PassWordView) getChildAt(inputIndex);
                    if (passWordView != null) {
                        passWordView.setmIsShowRemindLine(false);
                        passWordView.updateInputState(false);
                    }
                }
            }
        });

4.状态回调。这可回调接口是开放给使用者的,可以进行一些状态的监听。

    public interface pwdChangeListener {
        void onChange(String pwd);//密码改变

        void onNull();  //密码删除为空

        void onFinished(String pwd);//密码长度已经达到最大值
    }

除了回调,还提供了一些其他方法可供使用

removeAllPwd()//删除所有密码
getPassString()//获取输入密码

5.输入状态保存。当app运行在后台被回收时,页面恢复时原来的输入状态也要恢复。我们这里用横竖屏切换来模拟页面回收。


状态的保存则是通过重写 onRestoreInstanceState 方法和 onSaveInstanceState方法实现的。主要思路就是onSaveInstanceState中保存用户数据,然后在onRestoreInstanceState 中进行恢复。

整个功能实现的主要思路就是这样,用一个ViewGroup去管理下面的子View,或许相比纯绘制View,这种方法有点画蛇添足,稍显臃肿,但是实际上这和自定义View自己绘制状态的思想是一样的,不同的是我们这里使用ViewGroup去进行管理各种状态,子View负责绘制,这样实现的思路比较简洁,更容易理解。

最后放上实现代码:PassWordViewDemo

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,222评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,455评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,720评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,568评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,696评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,879评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,028评论 3 409
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,773评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,220评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,550评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,697评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,360评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,002评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,782评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,010评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,433评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,587评论 2 350

推荐阅读更多精彩内容