Android自定义View实现6位数字密码控件
记得遇到过见到这样需求:用户输入密码或者验证码的时候,对输入控件做样式格式控制,如下图所示:
EditText作为一个控件允许我们进行文字输入,遇到这种特殊的需求EditText就满足不了我们了,我们可以自定义View来实现以上功能。
分析一下需求,主要有这些功能点:
- 对于输入位数的控制。
- 用颜色和光标区分输入状态和为未输入状态。
- 输入框样式的控制:矩形、圆形、下划线。
- 输入内容的控制:只能输入数字,显示上分为密码文本(* 和 圆点)和输入数据文本。
自定义这样一个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 中我们主要做这几件事。
- 确定个数。
- 输入法监听。
- 焦点处理。
- 状态回调。
- 输入状态保存。
- 确定个数。
在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);
}
}
- 焦点处理。
变量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