分享一个验证码输入框自定义组件实现思路

395 阅读3分钟

验证码验证组件可以说是每个app的必备组件,一般情况下我们都需要根据UI设计自定义样式,

但大体都差不多。都是一个横向的布局,然后是4个或者6个子视图用于显示验证码。

1、因此我们可以使用一个横向的LinearLayout去放置子视图。

2、为了响应键盘弹出和接收键盘的输入,我们同时需要一个EditTextView,它的大小应该和LinearLayout一样,并且在它的正下方重叠。

3、正常情况下为了实现这种样式,我们需要再增加一层布局FrameLayout去放置,LinearLayout和EditTextView,但这样不好复用且会增加布局嵌套。

4、还有一种做法就是自定义viewgroup去实现,但要重写测量和布局。

5、我太懒了,不想写测量和布局,索性就复用LinearLayout的测量和布局,然后利用黑科技欺骗一下父LinearLayout,让它认为它没有子EditTextView,最后简单实现下EditTextView的测量和布局,保证它充满父LinearLayout。

class VerifyCodeView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : LinearLayout(context, attrs, defStyleAttr) {

    /**
     * 输入不可见的[AppCompatEditText],且隐藏光标,禁止光标的选中和移动
     */
    private class InVisibleEditText(context: Context) : AppCompatEditText(context) {

        init {
            setTextIsSelectable(false)
            isCursorVisible = false
            isLongClickable = false
            background = null
            setTextColor(Color.TRANSPARENT)
        }

        override fun onSelectionChanged(start: Int, end: Int) {
            text?.also {
                if (start != it.length || end != it.length) {
                    setSelection(it.length, it.length)
                    return
                }
            }
            super.onSelectionChanged(start, end)
        }
    }

    /**
     * 当验证码输入控件可输入时,它的方法将在验证码输入完成时调用
     */
    fun interface IVerifyCompletedListener {

        /**
         * 表示输入完成
         * @param code 验证码
         * @return 表示是否输入完成,ture表示输入完成,用户将不可再更改验证码,直到[reset]被调用,false表示
         * 继续处在输入中的状态,还将允许更改验证码
         */
        fun onCompleted(code: String): Boolean
    }

    /**
     * 欺骗[LinearLayout]的测量和布局
     */
    private var fakerFlag = false

    private var isFinishInflated = false

    /**
     * 0: 禁用
     * 1: 输入中
     * 2: 输入完成
     */
    private var mInputStatus = 0

    private lateinit var numViewArray: Array<TextView>

    private val inputView = InVisibleEditText(context)

    private var mListener: IVerifyCompletedListener? = null

    init {
        addView(inputView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
        inputView.isEnabled = false
        inputView.inputType = InputType.TYPE_CLASS_NUMBER
        inputView.addTextChangedListener {
            if (mInputStatus != 1) {
                return@addTextChangedListener
            }
            numViewArray.forEachIndexed { index, textView ->
                textView.text = it?.getOrNull(index)?.toString()
                textView.isSelected = it?.length == index
            }
            if (it?.length == numViewArray.size) {
                sendOnCompleted(it.toString())
            }
        }
    }

    override fun onViewAdded(child: View?) {
        if (isFinishInflated) {
            throw IllegalStateException("不支持动态增删child View")
        }
        super.onViewAdded(child)
    }

    override fun onViewRemoved(child: View?) {
        if (isFinishInflated) {
            throw IllegalStateException("不支持动态增删child View")
        }
        super.onViewRemoved(child)
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        fakerFlag = true
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        measureChild(inputView, MeasureSpec.makeMeasureSpec(measuredWidth, MeasureSpec.EXACTLY), heightMeasureSpec)
        fakerFlag = false
    }

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        fakerFlag = true
        inputView.layout(0, 0, inputView.measuredWidth, inputView.measuredHeight)
        super.onLayout(changed, l, t, r, b)
        fakerFlag = false
    }

    override fun getChildAt(index: Int): View {
        return if (fakerFlag) super.getChildAt(index + 1) else super.getChildAt(index)
    }

    override fun getChildCount(): Int {
        return if (fakerFlag) super.getChildCount() - 1 else super.getChildCount()
    }

    override fun onFinishInflate() {
        super.onFinishInflate()
        isFinishInflated = true
        val mChildCount = childCount
        if (mChildCount > 1) {
            mInputStatus = 1
            inputView.isEnabled = true
            inputView.filters = arrayOf<InputFilter>(InputFilter.LengthFilter(mChildCount.minus(1)))
            val code = buildString {
                var invalid = false
                numViewArray = Array(mChildCount.minus(1)) { index ->
                    (getChildAt(index.plus(1)) as TextView).also {
                        if (!invalid) {
                            val mText = it.text
                            if (mText.length == 1) {
                                append(mText)
                            } else {
                                invalid = true
                            }
                        }
                        it.inputType = InputType.TYPE_CLASS_NUMBER
                        it.maxEms = 1
                    }
                }
            }
            if (code.isDigitsOnly()) {
                inputView.setText(code)
            } else {
                inputView.setText("")
            }
        }
    }

    /**
     * 获取焦点,自动弹出软键盘
     */
    fun requestInputFocus(): Boolean {
        return inputView.requestFocus().also {
            // TODO: 弹出软键盘
        }
    }

    /**
     * 清空输入的内容,当不处于输入中的状态,调用无效。当输入完成时,如果也需要清空的话可以使用[reset]
     */
    fun clear() {
        if (mInputStatus != 1) {
            return
        }
        inputView.text = null
    }

    /**
     * 重置输入的状态,当处于禁用状态时,调用无效
     * @param clear 表示是否清空输入内容,true表示需要清空,false表示不清空
     */
    fun reset(clear: Boolean = true) {
        if (mInputStatus == 0) {
            return
        }
        mInputStatus = 1
        inputView.isEnabled = true
        if (clear) {
            inputView.text = null
        }
        post {
            requestInputFocus()
        }
    }

    /**
     * 获取当前输入的验证码,当处于禁用状态时,返回空字符串
     * @return 返回当前输入的验证码。
     */
    fun getVerifyCode(): String {
        if (mInputStatus == 0) {
            return ""
        }
        return inputView.text?.toString().orEmpty()
    }

    /**
     * 填充验证码,当不处于输入中的状态或填充的验证码长度超过最大限制,调用无效
     * @param code 验证码
     */
    fun autoInputFill(code: String) {
        if (mInputStatus != 1) {
            return
        }
        if (code.length > numViewArray.size) {
            return
        }
        inputView.setText(code)
    }

    /**
     * 监听输入完成通知
     * @param listener 监听回调
     */
    fun invokeOnCompleted(listener: IVerifyCompletedListener) {
        mListener = listener
    }

    /**
     * 清空[mListener]
     */
    fun clearListener() {
        mListener = null
    }

    private fun sendOnCompleted(code: String) {
        if (mListener?.onCompleted(code) == true) {
            mInputStatus = 2
            inputView.isEnabled = false
        }
    }

}

使用

    <VerifyCodeView
        android:layout_width="match_parent"
        android:layout_height="60dp"
        android:background="@color/white"
        android:gravity="center_vertical"
        app:layout_constraintBottom_toBottomOf="parent">

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginStart="20dp"
            android:text="1" />

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginStart="20dp"
            android:text="2" />

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginStart="20dp"
            android:text="3" />

    </VerifyCodeView>