Android 使用TextView实现验证码输入框

3,402 阅读8分钟

前言

网上开源的是构建同等数量的 EditText,这种存在很多缺陷,主要如下

1、数字 / 字符键盘切换后键盘状态无法保存
2、焦点切换无法判断
3、光标位置无法修正
4、切换过程需要做很多同步工作
5、需要处理聚焦选中区域问题
6、性能差

EditText越多,造成的不确定性问题将越多,因此,在开发中,如果我们自行实现一个纯View的输入框有没有可能呢?比较遗憾的是,Android 层面android.widget.Editor是非公开的类,因此很难去实现一个想要的View。

另一种方案,我们继承TextView,改写TextView的绘制逻辑也是可以。

为什么TextView是可以的呢?

  • 第一:TextView 本身可以输入任何文本
  • 第二:TextView 绘制方法中使用android.widget.Editor可以辅助keycode->文本转换
  • 第三:TextView 提供了光标等各种组件

核心步骤

为了解决上述问题,使用 TextView 实现输入框,这里需要解决的问题是

1、允许 TextView 可编辑输入,这点可以参考EditText的实现
2、重写 onDraw 实现,不实用原有的绘制逻辑。
3、重写光标逻辑,默认的光标逻辑和Editor有很多关联逻辑,而Editor是@hide标注的,因此必须要重写
4、重写长按菜单逻辑,防止弹出剪切、复制、选中等PopWindow弹窗。 5、限制文本长度

fire_89.gif

代码实现

首先我们要继承TextView或者AppCompatTextView,然后实现下面的操作

变量定义

//边框颜色
private int boxColor = Color.BLACK;

//光标是否可见
private boolean isCursorVisible = true;
//光标
private Drawable textCursorDrawable;
//光标宽度
private float cursorWidth = dp2px(2);
//光标高度
private float cursorHeight = dp2px(36);
//是否展示光标
private boolean isShowCursor;
//字符数量控制
private int inputBoxNum = 5;
//间距
private int mBoxSpace = 10;

关键状态

禁止复制、粘贴、选中

mrb62ges5a.jpeg

super.setFocusable(true); //支持聚焦
super.setFocusableInTouchMode(true); //支持触屏模式聚焦
//可点击,因为聚焦的view必须是可以点击的,这里你也可以设置个clickListener,效果一样
super.setClickable(true);
super.setGravity(Gravity.CENTER_VERTICAL);
super.setMaxLines(1);
super.setSingleLine();
super.setFilters(inputFilters);
super.setLongClickable(false);// 禁止复制、剪切
super.setTextIsSelectable(false); // 禁止选中

绘制逻辑

我们重写onDraw方法,自行绘制View

TextPaint paint = getPaint();

float strokeWidth = paint.getStrokeWidth();
if(strokeWidth == 0){
    //默认Text是没有strokeWidth的,为了防止绘制边缘存在问题,这里强制设置 1dp
    paint.setStrokeWidth(dp2px(1));
    strokeWidth = paint.getStrokeWidth();
}
paint.setTextSize(getTextSize());

float boxWidth = (getWidth() - strokeWidth * 2f - (inputBoxNum - 1) * mBoxSpace) / inputBoxNum;
float boxHeight = getHeight() - strokeWidth * 2f;
int saveCount = canvas.save();
//获取默认风格
Paint.Style style = paint.getStyle();
Paint.Align align = paint.getTextAlign();
paint.setTextAlign(Paint.Align.CENTER);

String text = getText().toString();
int length = text.length();

int color = paint.getColor();

for (int i = 0; i < inputBoxNum; i++) {

    inputRect.set(i * (boxWidth + mBoxSpace) + strokeWidth,
            strokeWidth,
            strokeWidth + i * (boxWidth + mBoxSpace) + boxWidth,
            strokeWidth + boxHeight);

    paint.setStyle(Paint.Style.STROKE);
    paint.setColor(boxColor);
    //绘制边框
    canvas.drawRoundRect(inputRect, boxRadius, boxRadius, paint);

    //设置当前TextColor
    int currentTextColor = getCurrentTextColor();
    paint.setColor(currentTextColor);
    paint.setStyle(Paint.Style.FILL);
    if (text.length() > i) {
        // 绘制文字,这里我们不过滤空格,当然你可以在InputFilter中处理
        String CH = String.valueOf(text.charAt(i));
        int baseLineY = (int) (inputRect.centerY() + getTextPaintBaseline(paint));//基线中间点的y轴计算公式
        canvas.drawText(CH, inputRect.centerX(), baseLineY, paint);
    }

    //绘制光标
    if(i == length && isCursorVisible && length < inputBoxNum){
        Drawable textCursorDrawable = getTextCursorDrawable();
        if(textCursorDrawable != null) {
            if (!isShowCursor) {
                textCursorDrawable.setBounds((int) (inputRect.centerX() - cursorWidth / 2f), (int) ((inputRect.height() - cursorHeight) / 2f), (int) (inputRect.centerX() + cursorWidth / 2f), (int) ((inputRect.height() - cursorHeight) / 2f + cursorHeight));
                textCursorDrawable.draw(canvas);
                isShowCursor = true; //控制光标闪烁 blinking
            } else {
                isShowCursor = false;//控制光标闪烁 no blink
            }
            removeCallbacks(invalidateCursor);
            postDelayed(invalidateCursor,500);
        }
    }
}

paint.setColor(color);
paint.setStyle(style);
paint.setTextAlign(align);

canvas.restoreToCount(saveCount);

InsertionHandleView问题

image.png

我们上文处理了各种可能出现的选中区域弹窗,然而一个很难处理的弹窗双击后会展示,评论区有同学也贴出来了。主要原因是Editor为了方便EditText选中,在内部使用了InsertionHandleView去展示一个弹窗,但这个弹窗并不是直接addView的,而是通过PopWindow展示的,具体可以参考下面源码。

实际上,掘金Android 客户端也有类似的问题,不过掘金app的实现方式是使用多个EditText实现的,点击的时候就会明显看到这个小雨点,其次还有光标卡顿的问题。

android.widget.Editor.InsertionHandleView

解决方法其实有3种:

第一种是Hack Context,返回一个自定义的WindowManager给PopWindow,不过我们知道InputManagerService 作为 WindowManagerService中的子服务,如果处理不当,可能产生输入法无法输入的问题,另外要Hack WindowManager,显然工作量很大。

第二种是替换:修改InsertionHandleView的背景元素,具体可参考:blog.csdn.net/shi_xin/art… 一文

<item name="textSelectHandleLeft">@drawable/text_select_handle_left_material</item>
<item name="textSelectHandleRight">@drawable/text_select_handle_right_material</item>
<item name="textSelectHandle">@drawable/text_select_handle_middle_material</item>

这种方式增加了View的可扩展性,自定义View要尽可能避免和xml配置耦合,除非是自定义属性。

第三种是拦截hide方法,在popWindow展示之后,会立即设置一个定时消失的逻辑,这种相对简单,而且View的通用性不受影响,但是也有些不规范,不过目前这个调用还是相当稳定的。

综上,我们选择第三种方案,我这里直接拦截其内部调用postDelay的方法,如果是InsertionHandleView的内部类,且时间为4000秒,直接执行runnable

private void hideAfterDelay() {
    if (mHider == null) {
        mHider = new Runnable() {
            public void run() {
                hide();
            }
        };
    } else {
        removeHiderCallback();
    }
    mTextView.postDelayed(mHider, DELAY_BEFORE_HANDLE_FADES_OUT);
}

下面是解法:

@Override
public boolean postDelayed(Runnable action, long delayMillis) {
    final long DELAY_BEFORE_HANDLE_FADES_OUT = 4000;
    if(delayMillis == DELAY_BEFORE_HANDLE_FADES_OUT
            && action.getClass().getName().startsWith("android.widget.Editor$InsertionHandleView$")){
        Log.d("TAG","delayMillis = " + delayMillis);
        delayMillis = 0;
    }
    return super.postDelayed(action, delayMillis);
}

总结

上面就是本文的核心逻辑,实际上EditText、Button都继承自TextView,因此我们简单的修改就能让其支持输入,主要原因还是TextView复杂的设计和各种Layout的支持,但是这也给TextView带来了性能问题。

这里简单说下TextView性能优化,对于单行文本和非可编辑文本,最好是自行实现,单行文本直接用canvas.drawText绘制,当然多行也是可以的,不过鉴于要支持很多特性,多行文本可以使用StaticLayout去实现,但单行文本尽量自己绘制,也不要使用BoringLayout,因为其存在一些兼容性问题,另外自定义的单行文本不要和TextView同一行布局,因为TextView的计算相对较多,很可能产生对不齐的问题。

本篇全部代码

按照惯例,这里依然提供全部代码,仅供参考,当然,也可以直接使用到项目中,本篇代码在线上已经使用过。

public class EditableTextView extends TextView {

    private RectF inputRect = new RectF();


    //边框颜色
    private int boxColor = Color.BLACK;

    //光标是否可见
    private boolean isCursorVisible = true;
    //光标
    private Drawable textCursorDrawable;
    //光标宽度
    private float cursorWidth = dp2px(2);
    //光标高度
    private float cursorHeight = dp2px(36);
    //光标闪烁控制
    private boolean isShowCursor;
    //字符数量控制
    private int inputBoxNum = 5;
    //间距
    private int mBoxSpace = 10;
    // box radius
    private float boxRadius = dp2px(0);

    InputFilter[] inputFilters = new InputFilter[]{
            new InputFilter.LengthFilter(inputBoxNum)
    };


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

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

    public EditableTextView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        super.setFocusable(true); //支持聚焦
        super.setFocusableInTouchMode(true); //支持触屏模式聚焦
        //可点击,因为在触屏模式可聚焦的view一般是可以点击的,这里你也可以设置个clickListener,效果一样
        super.setClickable(true);
        super.setGravity(Gravity.CENTER_VERTICAL);
        super.setMaxLines(1);
        super.setSingleLine();
        super.setFilters(inputFilters);
        super.setLongClickable(false);// 禁止复制、剪切
        super.setTextIsSelectable(false); // 禁止选中

        Drawable cursorDrawable = getTextCursorDrawable();
        if(cursorDrawable == null){
            cursorDrawable = new PaintDrawable(Color.MAGENTA);
            setTextCursorDrawable(cursorDrawable);
        }

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
            super.setPointerIcon(null);
        }
        super.setOnLongClickListener(new OnLongClickListener() {
            @Override
            public boolean onLongClick(View v) {
                return true;  //抑制长按出现弹窗的问题
            }
        });

        //禁用ActonMode弹窗
        super.setCustomSelectionActionModeCallback(null);

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            setBreakStrategy(LineBreaker.BREAK_STRATEGY_SIMPLE);
        }
        mBoxSpace = (int) dp2px(10f);

    }

    @Override
    public ActionMode startActionMode(ActionMode.Callback callback) {
        return null;
    }

    @Override
    public ActionMode startActionMode(ActionMode.Callback callback, int type) {
        return null;
    }

    @Override
    public boolean hasSelection() {
        return false;
    }

    @Override
    public boolean showContextMenu() {
        return false;
    }

    @Override
    public boolean showContextMenu(float x, float y) {
        return false;
    }

    public void setBoxSpace(int mBoxSpace) {
        this.mBoxSpace = mBoxSpace;
        postInvalidate();
    }

    public void setInputBoxNum(int inputBoxNum) {
        if (inputBoxNum <= 0) return;
        this.inputBoxNum = inputBoxNum;
        this.inputFilters[0] = new InputFilter.LengthFilter(inputBoxNum);
        super.setFilters(inputFilters);
    }

    @Override
    public void setClickable(boolean clickable) {

    }

    @Override
    public void setLines(int lines) {

    }
    @Override
    protected boolean getDefaultEditable() {
        return true;
    }


    @Override
    protected void onDraw(Canvas canvas) {

        TextPaint paint = getPaint();

        float strokeWidth = paint.getStrokeWidth();
        if(strokeWidth == 0){
            //默认Text是没有strokeWidth的,为了防止绘制边缘存在问题,这里强制设置 1dp
            paint.setStrokeWidth(dp2px(1));
            strokeWidth = paint.getStrokeWidth();
        }
        paint.setTextSize(getTextSize());

        float boxWidth = (getWidth() - strokeWidth * 2f - (inputBoxNum - 1) * mBoxSpace) / inputBoxNum;
        float boxHeight = getHeight() - strokeWidth * 2f;
        int saveCount = canvas.save();

        Paint.Style style = paint.getStyle();
        Paint.Align align = paint.getTextAlign();
        paint.setTextAlign(Paint.Align.CENTER);

        String text = getText().toString();
        int length = text.length();

        int color = paint.getColor();

        for (int i = 0; i < inputBoxNum; i++) {

            inputRect.set(i * (boxWidth + mBoxSpace) + strokeWidth,
                    strokeWidth,
                    strokeWidth + i * (boxWidth + mBoxSpace) + boxWidth,
                    strokeWidth + boxHeight);

            paint.setStyle(Paint.Style.STROKE);
            paint.setColor(boxColor);
            //绘制边框
            canvas.drawRoundRect(inputRect, boxRadius, boxRadius, paint);

            //设置当前TextColor
            int currentTextColor = getCurrentTextColor();
            paint.setColor(currentTextColor);
            paint.setStyle(Paint.Style.FILL);
            if (text.length() > i) {
                // 绘制文字,这里我们不过滤空格,当然你可以在InputFilter中处理
                String CH = String.valueOf(text.charAt(i));
                int baseLineY = (int) (inputRect.centerY() + getTextPaintBaseline(paint));//基线中间点的y轴计算公式
                canvas.drawText(CH, inputRect.centerX(), baseLineY, paint);
            }

            //绘制光标
            if(i == length && isCursorVisible && length < inputBoxNum){
                Drawable textCursorDrawable = getTextCursorDrawable();
                if(textCursorDrawable != null) {
                    if (!isShowCursor) {
                        textCursorDrawable.setBounds((int) (inputRect.centerX() - cursorWidth / 2f), (int) ((inputRect.height() - cursorHeight) / 2f), (int) (inputRect.centerX() + cursorWidth / 2f), (int) ((inputRect.height() - cursorHeight) / 2f + cursorHeight));
                        textCursorDrawable.draw(canvas);
                        isShowCursor = true; //控制光标闪烁 blinking
                    } else {
                        isShowCursor = false;//控制光标闪烁 no blink
                    }
                    removeCallbacks(invalidateCursor);
                    postDelayed(invalidateCursor,500);
                }
            }
        }

        paint.setColor(color);
        paint.setStyle(style);
        paint.setTextAlign(align);

        canvas.restoreToCount(saveCount);
    }


    private Runnable invalidateCursor = new Runnable() {
        @Override
        public void run() {
            invalidate();
        }
    };
    
  
  //避免paint.getFontMetrics内部频繁创建对象
Paint.FontMetrics fm = new Paint.FontMetrics();  

    /**
     * 基线到中线的距离=(Descent+Ascent)/2-Descent
     * 注意,实际获取到的Ascent是负数。公式推导过程如下:
     * 中线到BOTTOM的距离是(Descent+Ascent)/2,这个距离又等于Descent+中线到基线的距离,即(Descent+Ascent)/2=基线到中线的距离+Descent。
     */

public float getTextPaintBaseline(Paint p) {
    p.getFontMetrics(fm);
    Paint.FontMetrics fontMetrics = fm;
    return (fontMetrics.descent - fontMetrics.ascent) / 2 - fontMetrics.descent;
}

    /**
     * 控制是否保存完整文本
     *
     * @return
     */
    @Override
    public boolean getFreezesText() {
        return true;
    }

    @Override
    public Editable getText() {
        return (Editable) super.getText();
    }

    @Override
    public void setText(CharSequence text, BufferType type) {
        super.setText(text, BufferType.EDITABLE);
    }

    /**
     * 控制光标展示
     *
     * @return
     */
    @Override
    protected MovementMethod getDefaultMovementMethod() {
        return ArrowKeyMovementMethod.getInstance();
    }

    @Override
    public boolean isCursorVisible() {
        return isCursorVisible;
    }

    @Override
    public void setTextCursorDrawable(@Nullable Drawable textCursorDrawable) {
//        super.setTextCursorDrawable(null);
        this.textCursorDrawable = textCursorDrawable;
        postInvalidate();
    }

    @Nullable
    @Override
    public Drawable getTextCursorDrawable() {
        return textCursorDrawable;  //支持android Q 之前的版本
    }

    @Override
    public void setCursorVisible(boolean cursorVisible) {
        isCursorVisible = cursorVisible;
    }
    public float dp2px(float dp) {
        return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, getResources().getDisplayMetrics());
    }

    public void setBoxRadius(float boxRadius) {
        this.boxRadius = boxRadius;
        postInvalidate();
    }

    public void setBoxColor(int boxColor) {
        this.boxColor = boxColor;
        postInvalidate();
    }

    public void setCursorHeight(float cursorHeight) {
        this.cursorHeight = cursorHeight;
        postInvalidate();
    }

    public void setCursorWidth(float cursorWidth) {
        this.cursorWidth = cursorWidth;
        postInvalidate();
    }
    
    @Override
public boolean postDelayed(Runnable action, long delayMillis) {
    final long DELAY_BEFORE_HANDLE_FADES_OUT = 4000;
    if(delayMillis == DELAY_BEFORE_HANDLE_FADES_OUT
            && action.getClass().getName().startsWith("android.widget.Editor$InsertionHandleView$")){
        delayMillis = 0;
    }
    return super.postDelayed(action, delayMillis);
}

}