注册

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

前言


网上开源的是构建同等数量的 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);
}

}


作者:时光少年
来源:juejin.cn/post/7313242064196452361

0 个评论

要回复文章请先登录注册