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

前言

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

1、数字 / 字符键盘切换后键盘状态无法保存

2、焦点切换无法判断

3、光标位置无法修正

4、切换过程需要做很多同步工作

解决方法

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

1、允许 TextView 可编辑输入,这点可以参考EditText的实现

2、重写 onDraw 实现 3、重写光标逻辑 4、重写长按菜单逻辑

代码实现

变量定义

java 复制代码
//边框颜色
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;

关键设置

java 复制代码
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); // 禁止选中

绘制逻辑

java 复制代码
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);

总结

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

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

本篇全部代码

按照惯例,这里依然提供全部代码,仅供参考。

java 复制代码
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(new ActionMode.Callback() {
            @Override
            public boolean onCreateActionMode(ActionMode mode, Menu menu) {
                return false;
            }

            @Override
            public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
                return false;
            }

            @Override
            public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
                return false;
            }

            @Override
            public void onDestroyActionMode(ActionMode mode) {

            }
        });

        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();
        }
    };
    /**
     * 基线到中线的距离=(Descent+Ascent)/2-Descent
     * 注意,实际获取到的Ascent是负数。公式推导过程如下:
     * 中线到BOTTOM的距离是(Descent+Ascent)/2,这个距离又等于Descent+中线到基线的距离,即(Descent+Ascent)/2=基线到中线的距离+Descent。
     */
    public static float getTextPaintBaseline(Paint p) {
        Paint.FontMetrics fontMetrics = p.getFontMetrics();
        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();
    }

}
相关推荐
天蓝色的鱼鱼2 小时前
前端开发者的组件设计之痛:为什么我的组件总是难以维护?
前端·react.js
codingandsleeping2 小时前
使用orval自动拉取swagger文档并生成ts接口
前端·javascript
考虑考虑2 小时前
Jpa使用union all
java·spring boot·后端
石金龙3 小时前
[译] Composition in CSS
前端·css
用户3721574261353 小时前
Java 实现 Excel 与 TXT 文本高效互转
java
白水清风3 小时前
微前端学习记录(qiankun、wujie、micro-app)
前端·javascript·前端工程化
Ticnix3 小时前
函数封装实现Echarts多表渲染/叠加渲染
前端·echarts
用户22152044278003 小时前
new、原型和原型链浅析
前端·javascript
阿星做前端3 小时前
coze源码解读: space develop 页面
前端·javascript
叫我小窝吧3 小时前
Promise 的使用
前端·javascript