之前公司有过需求,要求做一个类似滴滴打车输入验证码的页面,长这样:

emmmmmm,好像截了iOS的图,不要在意这些细节。

来分析一下这个验证码部分,实现这样一个自定义View,首先,要区分单个验证码选中状态和未选中状态,并且光标悬停在选中的验证码中心,其次, 每次输入文字后需要依次显示在每个单独的验证码容器中,还有诸如自定义验证码选中状态、清空输入验证码等等。受到博文Android 自定义View之带密码模式的正方形验证码输入框的启发,我决定使用继承RelativeLayout的方式来完成这个自定义View。

实现思路

笔者这里决定继承RelativeLayout来实现自定义验证码的功能。显示验证码可以使用几个TextView,这里需要将TextView统一管理,所以还需要一个TextView数组。有光标,那自然而然的就想到了EditText,可以使用一个透明背景的EditText。几个验证码可以使用TextView以一定的规则进行排列,通过监听EditText的输入,拦截到输入字符,并将字符传递给TextView数组,并将EditText置为空,同时重设TextView选中状态,移动EditText光标。笔者这里采用给EditText设置paddingLeft的方式来实现光标的移动,当然,需要经过一些计算。OK,大概思路就是这样了,具体的代码我们继续看下面的。

具体实现

首先,我们需要一个类来继承RelativeLayout,并且给这个类分配上一些自定义属性,在attrs.xml文件中定义以下属性:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <!-- 自定义验证码-->
    <declare-styleable name="VerificationCodeView">
        <!--输入框的数量-->
        <attr name="vcv_code_number" format="integer" />
        <!--单个验证码的宽度-->
        <attr name="vcv_code_width" format="dimension|reference" />
        <!--输入框文字颜色-->
        <attr name="vcv_code_color" format="color|reference" />
        <!--输入框文字大小-->
        <attr name="vcv_code_size" format="dimension|reference" />
        <!--输入框获取焦点时边框-->
        <attr name="vcv_code_bg_focus" format="reference" />
        <!--输入框没有焦点时边框-->
        <attr name="vcv_code_bg_normal" format="reference" />
    </declare-styleable>
</resources>

具体属性都再代码中注释出来了,接着,在Java类中取出这些属性:

/**
 * @author 小米Xylitol
 * @email xiaomi987@hotmail.com
 * @desc Android验证码View
 * @date 2018-05-09 10:25
 */
public class VerificationCodeView extends RelativeLayout {

    //当前验证码View用来展示验证码的TextView数组,数组个数由codeNum决定
    private TextView[] textViews;
    //用来输入验证码的EditText输入框,输入框会跟随输入个数移动,显示或者隐藏光标等
    private WiseEditText editText;
    //当前验证码View展示验证码个数
    private int codeNum;
    //每个TextView的宽度
    private float codeWidth;
    //字体颜色
    private int textColor;
    //字体大小
    private int textSize;
    //每个单独验证码的背景
    private Drawable textDrawable;
    //验证码选中时的背景
    private Drawable textFocusedDrawable;
    //验证码之间间隔
    private float dividerWidth = 0;
    //对EditText输入进行监听
    private TextWatcher watcher;
    //监听删除键和enter键
    private OnKeyListener onKeyListener;

    //当前选中的TextView位置,即光标所在位置
    private int currentFocusPosition = 0;

    public VerificationCodeView(Context context) {
        this(context,null);
    }

    public VerificationCodeView(Context context, AttributeSet attrs) {
        this(context, attrs,0);
    }

    public VerificationCodeView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(context,attrs,defStyleAttr);
    }

    /**
     * 初始化View
     * @param context
     * @param attrs
     * @param defStyleAttr
     */
    private void init(Context context, AttributeSet attrs, int defStyleAttr) {
        TypedArray array = context.obtainStyledAttributes(attrs,R.styleable.VerificationCodeView,defStyleAttr,0);
        codeNum = array.getInteger(R.styleable.VerificationCodeView_vcv_code_number,4);
        codeWidth = array.getDimensionPixelSize(R.styleable.VerificationCodeView_vcv_code_width, (int) dip2px(50,context));
        textColor = array.getColor(R.styleable.VerificationCodeView_vcv_code_color,getResources().getColor(R.color.text_border_focused));
        textSize = array.getDimensionPixelSize(R.styleable.VerificationCodeView_vcv_code_size,getResources().getDimensionPixelOffset(R.dimen.text_size));
        textDrawable = array.getDrawable(R.styleable.VerificationCodeView_vcv_code_bg_normal);
        textFocusedDrawable = array.getDrawable(R.styleable.VerificationCodeView_vcv_code_bg_focus);
        array.recycle();
        //若未设置选中和未选中状态,设置默认
        if(textDrawable == null) {
            textDrawable = getResources().getDrawable(R.drawable.bg_text_normal);
        }
        if(textFocusedDrawable == null) {
            textFocusedDrawable = getResources().getDrawable(R.drawable.bg_text_focused);
        }

        initView(context);
        initListener();
        resetCursorPosition();
    }
}

可以看到我们这里依次取出了验证码字数、单个验证码宽度(这里默认是正方形)、字体颜色、字体大小、获取焦点背景、失去焦点背景等,并设置了默认值。最后别忘了调用array.recycler()释放资源。

接下来就是对TextView数组textViews以及editText的初始化工作了,在方法initView中:

/**
     * 初始化各View并加入当前View中
     */
private void initView(Context context) {
    //初始化TextView数组
    textViews = new TextView[codeNum];
    for (int i = 0;i < codeNum;i++) {
        //循环加入数组中,设置TextView字体大小和颜色,并将TextView依次加入LinearLayout
        TextView textView = new TextView(context);
        textView.setWidth((int) codeWidth);
        textView.setHeight((int) codeWidth);
        textView.setGravity(Gravity.CENTER);
        textView.setTextColor(textColor);
        textView.setTextSize(textSize);
        textViews[i] = textView;
        this.addView(textView);
        RelativeLayout.LayoutParams params = (LayoutParams) textView.getLayoutParams();
        params.addRule(CENTER_VERTICAL);
    }
    //初始化EditText,设置背景色为透明,获取焦点,设置光标颜色,设置输入类型等
    editText = new WiseEditText(context);
    editText.setBackgroundColor(Color.TRANSPARENT);
    editText.requestFocus();
    editText.setInputType(InputType.TYPE_CLASS_NUMBER);
    setCursorRes(R.drawable.cursor);
    addView(editText);
}

这里做了一个循环,将TextView添加进数组中,并将TextView添加进RelativeLayout中,这里注意将TextView设置为纵向居中。EditText的设置比较简单,将其背景设置为透明,并将传入的光标通过反射设置给EditText,设置其输入类型(我这里偷个懒直接设置了TYPE_CLASS_NUMBER),最后将EditText加入RelativeLayout中。

这一步完成之后我们可以想象,现在的View是几个TextView和EditText堆叠在一起,下一步我们给它们各自调整位置,实现TextView居中,EditText充满父控件。由于我们需要TextView分散居中,需要获取到当前控件的宽度,那么代码就需要在onMeasure之后来执行,笔者这里选择在onLayout中执行设置的代码,在这之前需要考虑在当前控件是wrap_content时重设其高度:

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    int heightMode = MeasureSpec.getMode(heightMeasureSpec);
    if (heightMode == MeasureSpec.AT_MOST) {
        //若高度是wrap_content,则设置为50dp
        heightMeasureSpec = MeasureSpec.makeMeasureSpec((int) dip2px(80, getContext()), MeasureSpec.EXACTLY);
    }
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
    //获取到宽高,可以对TextView进行摆放
    layoutTextView();
    super.onLayout(changed, l, t, r, b);
}
/**
     * onMeasure后获取到测量的控件宽度,计算出每个Code之间的间隔
     */
private void layoutTextView() {
    //获取控件剩余宽度
    float availableWidth = getMeasuredWidth() - getPaddingLeft() - getPaddingRight();
    if(codeNum > 1) {
        //计算每个Code之间的间距
        dividerWidth = (availableWidth - codeWidth * codeNum) / (codeNum - 1);
        for(int i = 1;i < codeNum;i++) {
            float leftMargin = codeWidth * i + dividerWidth * i;
            RelativeLayout.LayoutParams params1 = (RelativeLayout.LayoutParams) textViews[i].getLayoutParams();
            params1.leftMargin = (int) leftMargin;
        }
    }

    //设置EditText宽度从第一个Code左侧到最后一个Code右侧,设置高度为Code高度
    //设置EditText为纵向居中
    editText.setWidth((int) availableWidth);
    editText.setHeight((int) codeWidth);
    RelativeLayout.LayoutParams params = (LayoutParams) editText.getLayoutParams();
    params.addRule(CENTER_VERTICAL);
}

这里的layoutTextView方法就是一个摆放TextView的过程,通过getMeasuredWidth获取当前View的宽度,减去左侧右侧内边距,获得子View可用的宽度。算出各个TextView之间的间距,然后逐个设置左边距即可。

EditText就很简单,设置宽度占满父控件,并且垂直居中即可。

接下来是设置EditText的相关监听:

/**
 * 监听EditText输入字符,监听键盘删除键
 */
private void initListener() {
    watcher = new TextWatcher() {
        @Override
        public void beforeTextChanged(CharSequence s, int start, int count, int after) {

        }

        @Override
        public void onTextChanged(CharSequence s, int start, int before, int count) {

        }

        @Override
        public void afterTextChanged(Editable s) {
            String content = s.toString();
            if(!TextUtils.isEmpty(content)) {
                if(content.length() == 1) {
                    setText(content);
                }
                editText.setText("");
            }
        }
    };
    editText.addTextChangedListener(watcher);

    onKeyListener = new OnKeyListener() {
        @Override
        public boolean onKey(View v, int keyCode, KeyEvent event) {
            if(keyCode == KeyEvent.KEYCODE_DEL && event.getAction() == KeyEvent.ACTION_DOWN) {
                deleteCode();
                return true;
            }
            return false;
        }
    };
    editText.setSoftKeyListener(onKeyListener);
}
/**
 * 删除键按下
 */
private void deleteCode() {
    if(currentFocusPosition == 0) {
        //当前光标位置在0,直接返回
        return;
    } else {
        //当前光标不为0,当前光标位置-1,将当前光标位置TextView置为"",重设光标位置
        currentFocusPosition--;
        textViews[currentFocusPosition].setText("");
        resetCursorPosition();
    }
}

/**
 * 拦截到EditText输入字符,发送给该方法进行处理
 * @param s
 */
private void setText(String s) {
    if(currentFocusPosition >= codeNum) {
        //光标已经隐藏,直接返回
        return;
    }
    //设置字符给当前光标位置的TextView,光标位置后移,重设光标状态
    textViews[currentFocusPosition].setText(s);
    currentFocusPosition++;
    resetCursorPosition();
}

这里由于原生EditText在监听软键盘删除按钮时有一些缺陷,某些情况下会出现监听不到的现象。我使用了一个自定义的EditText,具体代码可以下载代码查看,我这里就不多做赘述。监听到删除按钮被按下后,将当前光标所在位置向前移动一位,将移动后位置的TextView设置为空,并且重设光标和选中状态。

监听EditText变化的思路很简单,内容有变化,则取出内容,设置给TextView,并且移动光标位置,将EditText置为空。注意这里editText.setText("")一定要放在非空判断里执行,不然会死循环。在setText方法中与删除逻辑大同小异,先判断光标是否已经隐藏(是否已经全部输入),若未全部输入,将字符设置给当前光标所在位置的TextView,将光标位置后移,并且重设光标和选中状态。

我们来看下重设光标和选中状态的代码:

/**
     * 重设EditText的光标位置,以及选中TextView的边框颜色
     */
private void resetCursorPosition() {
    for(int i = 0;i < codeNum;i++) {
        TextView textView = textViews[i];
        if(i == currentFocusPosition) {
            textView.setBackgroundDrawable(textFocusedDrawable);
        } else {
            textView.setBackgroundDrawable(textDrawable);
        }
    }
    if(codeNum > 1) {
        if(currentFocusPosition < codeNum) {
            //字数小于总数,设置EditText的leftPadding,造成光标移动的错觉
            editText.setCursorVisible(true);
            float leftPadding = codeWidth / 2 + currentFocusPosition * codeWidth + currentFocusPosition * dividerWidth;
            editText.setPadding((int) leftPadding, 0, 0, 0);
        } else {
            //字数大于总数,隐藏光标
            editText.setCursorVisible(false);
        }
    }
}

简单来讲就是遍历数组,将对应的选中TextView设置为选中状态,其他则设置为未选中状态。在验证码字数大于1(这不废话嘛)的情况下,隐藏或者展示光标,并根据间距设置EditText的leftPadding值,逻辑很简单,看代码即可。

到这里我们的整个逻辑就已经实现了。看下效果图

看起来还是蛮像那么回事儿的。

使用

完成了主体逻辑后,我们给自定义View来添加几个公共方法:

/**
     * 暴露公共方法,设置光标颜色
     * @param drawableRes
     */
public void setCursorRes(@DrawableRes int drawableRes) {
    try {
        java.lang.reflect.Field f = TextView.class.getDeclaredField("mCursorDrawableRes");
        f.setAccessible(true);
        f.set(editText, drawableRes);
    } catch (Exception e) {
    }
}

/**
     * 获取输入的验证码
     * @return
     */
public String getContent() {
    StringBuilder builder = new StringBuilder();
    for(TextView tv : textViews) {
        builder.append(tv.getText());
    }
    return builder.toString();
}

/**
     * 判断是否验证码输入完毕
     * @return
     */
public boolean isFinish() {
    for(TextView tv : textViews) {
        if(TextUtils.isEmpty(tv.getText())) {
            return false;
        }
    }
    return true;
}

/**
     * 清除已输入验证码
     */
public void clear() {
    for(TextView tv : textViews) {
        tv.setText("");
    }
    currentFocusPosition = 0;
    resetCursorPosition();
}

在xml中,我们可以这样使用该自定义View:

<com.xylitolz.androidverificationcode.view.VerificationCodeView
        android:id="@+id/view_verification"
        android:layout_marginTop="20dp"
        android:layout_width="240dp"
        android:layout_height="wrap_content"
        app:vcv_code_size="16sp"
        app:vcv_code_bg_focus="@drawable/bg_text_focused"
        app:vcv_code_bg_normal="@drawable/bg_text_normal"
        app:vcv_code_color="@color/text_border_focused"
        app:vcv_code_number="4"
        app:vcv_code_width="50dp"/>

接下来是在Activity中的使用演示:

OK,以上就是本次博客的全部内容了,如果您对本文有任何疑问或者文章有错误或者遗漏,请在评论留言告诉我,不胜感激~

本文代码地址github,欢迎fork~
我的个人博客,欢迎访问~

enjoy~

Logo

权威|前沿|技术|干货|国内首个API全生命周期开发者社区

更多推荐