自定义验证码输入View

我们现在要实现一个自定义输入框,显示用户接受到的验证码,大概是长这样

思路分析

Q:需要有哪些功能?

A:框代表的是验证码长度,这个需要可配置;只接收数字并可以编辑删除;输入和未输入有状态区别

Q:可以怎么实现?

A:直觉上是直接继承EditView,重写onDraw方法

Q:为什么要继承EditView而不是TextView甚至是View?

A:如果继承View,那控件的逻辑能力也需要自己实现,比如输入法的弹出和隐藏,输入法内容的接收需要重写onCreateInputConnection等方法。EditView比TextView多的能力是设置Selection光标位置,文字默认可编辑可定义输入法类型。

综上:该自定义需求,系统控件的EditView功能上可以满足,只是需要自定义魔改下UI。

码上

1,新建一个自定义View继承于EditView,并复写onDraw和onMeasure

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class CustomInputView2 : EditText {
constructor(context: Context?) : super(context)
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)

// 控件需要知道自己的大小和边距padding,才能符合预期的绘制
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
}

// 绘制框框和数字
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
}
}

2,仔细看一下onMeasure

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
var widthSize = MeasureSpec.getSize(widthMeasureSpec)
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
var heightSize = MeasureSpec.getSize(heightMeasureSpec)
val realWidth = itemWith.toInt() * itemNum
val realHeight = itemWith.toInt()
// 当控件类型指定为wrap_content时,它的mode是MeasureSpec.AT_MOST,
// 具体大小需要自己计算出来,算的时候要加上对应padding
when {
widthMode == MeasureSpec.AT_MOST && heightMode == MeasureSpec.AT_MOST -> {
widthSize = realWidth + paddingLeft + paddingRight
heightSize = realHeight + paddingTop + paddingBottom
}
widthMode == MeasureSpec.AT_MOST -> {
widthSize = realWidth + paddingLeft + paddingRight
}
heightMode == MeasureSpec.AT_MOST -> {
heightSize = realHeight + paddingTop + paddingBottom
}
}
//calculate itemGap with padding, 本控件设计占一行,那每个框之间的间隔是需要算出来并赋值的
itemGap = (widthSize - (paddingStart + paddingEnd) - realWidth) / (itemNum - 1)
setMeasuredDimension(widthSize, heightSize)
}

3,把元素绘制上去,onDraw

画图三要素:画布(canvas),画笔(paint),画哪里(LRTB)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// 1,绘制框,调用canvas API drawRoundRect(rectF, itemRadius, itemRadius, paint)
// 2,需要rectF来确定画在哪里,比如第一个框:
// val top = paddingTop.toFloat()
// val bottom = height - paddingBottom.toFloat()
// val left = paddingStart
// val reght = itemWith + paddingStart
// rectF.set(left, top, right, bottom)
// 3,绘制剩下的框只需要left和right加对应的itemWith和itemGap
// 4,绘制文字调用canvas API drawText(String text, float x, float y, Paint paint)
// 5,文字居中:paint需要设置textAlign = Paint.Align.CENTER,这样可以保证x处于文字的中点,Y轴的中
// 点由于文字渲染的原因,需要计算一个偏移量
// val fontBottom = paintText.fontMetrics?.ascent ?: 0F
// val fontTop = paintText.fontMetrics?.descent ?: 0F
// val baseLineY = Math.abs(fontBottom + fontTop) / 2

override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)

val textLength = currentText?.length ?: 0
// font baseline
val fontBottom = paintText.fontMetrics?.ascent ?: 0F
val fontTop = paintText.fontMetrics?.descent ?: 0F
val baseLineY = Math.abs(fontBottom + fontTop) / 2
for (i in 0 until itemNum) {
val left = i * (itemWith + itemGap) + paddingStart
val right = i * (itemWith + itemGap) + itemWith + paddingStart
val top = paddingTop.toFloat()
val bottom = height - paddingBottom.toFloat()
rectF.set(left, top, right, bottom)
if (i < textLength) {
// draw input string and set color for select paint
paint.color = paintSelectColor
val textToDraw = if (itemText.isNullOrEmpty()) currentText!![i].toString() else itemText ?: ""
canvas?.drawText(
textToDraw,
left + (right - left) / 2,
(top + bottom) / 2F + baseLineY,
paintText
)
} else {
// set color for normal paint
paint.color = paintNormalColor
}

// draw bg as the type
when (type) {
Type.Line -> canvas?.drawLine(left, bottom, right, bottom, paint)
Type.Square -> canvas?.drawRoundRect(rectF, itemRadius, itemRadius, paint)
}
}
}

4,控件可以自定义字段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<declare-styleable name="CustomInputView">
<attr name="item_with" format="dimension"/>
<attr name="item_gap" format="dimension"/>
<attr name="item_radius" format="dimension"/>
<attr name="item_num" format="integer"/>
<attr name="item_text" format="string"/>
<attr name="normal_color" format="color"/>
<attr name="select_color" format="color"/>
<attr name="text_color" format="color"/>
<attr name="type" format="enum">
<enum name="line" value="0"/>
<enum name="square" value="1"/>
</attr>
</declare-styleable>

5,继承于EditView,需要初始化一些字段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 输入类型指定为数字键盘
inputType = InputType.TYPE_CLASS_NUMBER
isCursorVisible = false // 光标不可见
isLongClickable = false // 不能长按
setTextIsSelectable(false) // 文字不能被选择复制
setOnClickListener { setSelection(text.length) } // 点击重置光标位置,虽然不可见
setBackgroundColor(UtilResource.getColor(R.color.transparent)) // 隐藏默认那根线
setTextColor(UtilResource.getColor(R.color.transparent)) // 隐藏默认的文字
filters = arrayOf(PinPwdFormatterFilter(itemNum)) // 设置filter,只要数字
addTextChangedListener(object : DefaultTextWatcher() {
override fun afterTextChanged(s: Editable?) {
super.afterTextChanged(s)
if (s.toString().length == itemNum) onInputComplete(s.toString())
}
})

参考文章

https://www.jianshu.com/p/8b97627b21c4