Android 自定义验证码输入框

139 阅读3分钟

image.png

image.png


import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Rect
import android.text.InputFilter
import android.text.InputType
import android.util.AttributeSet
import android.util.TypedValue
import android.view.KeyEvent
import android.view.View
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.widget.LinearLayout
import android.animation.ObjectAnimator
import android.animation.AnimatorListenerAdapter
import android.view.animation.CycleInterpolator

/**
 * A custom view for 4-digit verification code input
 * Features:
 * - Four separate digit boxes
 * - Auto-focus movement to next box after input
 * - Auto-focus movement to previous box on delete
 * - Callback when all digits are entered
 * - Customizable appearance
 */
class VerificationCodeView @JvmOverloads constructor(
  context: Context,
  attrs: AttributeSet? = null,
  defStyleAttr: Int = 0
) : LinearLayout(context, attrs, defStyleAttr) {
  
  /**
   * Interface for verification code completion callback
   */
  interface OnVerificationCodeCompleteListener {
   fun onVerificationCodeComplete(code: String)
  }
  
  /**
   * Show error state with shake animation
   */
  fun showError() {
   isError = true
   invalidate()
   startShakeAnimation()
  }
  
  /**
   * Clear error state
   */
  private fun clearError() {
   if (!isError) return
   isError = false
   invalidate()
  }
  
  /**
   * Set listener for shake animation end
   */
  fun setOnShakeAnimationEndListener(listener: () -> Unit) {
   onShakeAnimationEndListener = listener
  }
  
  /**
   * Start shake animation
   */
  private fun startShakeAnimation() {
   shakeAnimator?.cancel()
   shakeAnimator = ObjectAnimator.ofFloat(this, "translationX", 0f, 25f).apply {
    duration = 500
    interpolator = CycleInterpolator(3f)
    addListener(object : AnimatorListenerAdapter() {
     override fun onAnimationEnd(animation: android.animation.Animator) {
      onShakeAnimationEndListener?.invoke()
     }
    })
   }
   shakeAnimator?.start()
  }
  
  // UI Properties
  private var boxCount = 4
  private var boxSize = dpToPx(50f)
  private var boxMargin = dpToPx(10f)
  private var boxCornerRadius = dpToPx(8f)
  private var boxBorderWidth = dpToPx(2f)
  private var boxBorderColor = Color.parseColor("#CCCCCC")
  private var boxFocusedBorderColor = Color.parseColor("#009688")
  private var boxErrorBorderColor = Color.parseColor("#FF0000")
  private var boxBackgroundColor = Color.parseColor("#FFFFFF")
  private var textColor = Color.parseColor("#000000")
  private var textSize = spToPx(18f)
  
  // Animation Properties
  private var isError = false
  private var shakeAnimator: ObjectAnimator? = null
  private var onShakeAnimationEndListener: (() -> Unit)? = null
  
  // Input fields
  private val digitBoxes = ArrayList<DigitBox>()
  
  // Callback
  private var verificationCodeCompleteListener: OnVerificationCodeCompleteListener? = null
  
  init {
   orientation = HORIZONTAL
   createDigitBoxes()
  }
  
  /**
   * Create the digit input boxes
   */
  private fun createDigitBoxes() {
   removeAllViews()
   digitBoxes.clear()
   
   for (i in 0 until boxCount) {
    val digitBox = DigitBox(context)
    digitBox.id = View.generateViewId()
    
    // Configure the digit box
    digitBox.setTextColor(textColor)
    digitBox.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
    digitBox.setBackgroundColor(Color.TRANSPARENT)
    digitBox.inputType = InputType.TYPE_CLASS_NUMBER
    digitBox.filters = arrayOf(InputFilter.LengthFilter(1))
    digitBox.maxLines = 1
    digitBox.isCursorVisible = false
    digitBox.imeOptions = EditorInfo.IME_ACTION_NEXT
    
    // Set layout parameters
    val params = LayoutParams(
     boxSize.toInt(),
     boxSize.toInt()
    )
    if (i > 0) {
     params.leftMargin = boxMargin.toInt()
    }
    digitBox.layoutParams = params
    
    // Set focus change listener
    digitBox.setOnFocusChangeListener { _, _ ->
     digitBox.invalidate()
    }
    
    // Set key listener for handling backspace
    digitBox.setOnKeyListener { _, keyCode, event ->
     if (keyCode == KeyEvent.KEYCODE_DEL && event.action == KeyEvent.ACTION_DOWN) {
      if (digitBox.text.isNullOrEmpty() && i > 0) {
       clearError()
       // Move to previous box on delete when current is empty
       digitBoxes[i - 1].requestFocus()
       digitBoxes[i - 1].text?.clear()
       return@setOnKeyListener true
      }
     }
     false
    }
    
    // Set text change listener
    digitBox.addTextChangedListener(object : android.text.TextWatcher {
     override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
     
     override fun onTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
     
     override fun afterTextChanged(s: android.text.Editable?) {
      if (s?.length == 1) {
       // Move to next box if available
       if (i < boxCount - 1) {
        digitBoxes[i + 1].requestFocus()
       } else {
        // Last box filled, check if code is complete
        checkCodeCompletion()
        // Hide keyboard
        val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
        imm.hideSoftInputFromWindow(digitBox.windowToken, 0)
       }
      }
     }
    })
    
    digitBoxes.add(digitBox)
    addView(digitBox)
   }
  }
  
  /**
   * Check if all digits are entered and trigger callback
   */
  private fun checkCodeCompletion() {
   val code = getCode()
   if (code.length == boxCount) {
    verificationCodeCompleteListener?.onVerificationCodeComplete(code)
   }
  }
  
  /**
   * Get the current verification code
   */
  fun getCode(): String {
   val codeBuilder = StringBuilder()
   for (digitBox in digitBoxes) {
    codeBuilder.append(digitBox.text.toString())
   }
   return codeBuilder.toString()
  }
  
  /**
   * Set the verification code
   */
  fun setCode(code: String) {
   clearCode()
   
   val codeLength = minOf(code.length, boxCount)
   for (i in 0 until codeLength) {
    digitBoxes[i].setText(code[i].toString())
   }
   
   // Set focus to the next empty box or the last box
   if (code.length < boxCount) {
    digitBoxes[code.length].requestFocus()
   } else {
    digitBoxes.last().requestFocus()
   }
  }
  
  /**
   * Clear the verification code
   */
  fun clearCode() {
   for (digitBox in digitBoxes) {
    digitBox.text?.clear()
   }
   digitBoxes.firstOrNull()?.requestFocus()
  }
  
  /**
   * Set a listener for verification code completion
   */
  fun setOnVerificationCodeCompleteListener(listener: OnVerificationCodeCompleteListener) {
   verificationCodeCompleteListener = listener
  }
  
  /**
   * Set the number of digit boxes
   */
  fun setBoxCount(count: Int) {
   if (count > 0 && count != boxCount) {
    boxCount = count
    createDigitBoxes()
   }
  }
  
  /**
   * Set the box size
   */
  fun setBoxSize(size: Float) {
   boxSize = dpToPx(size)
   requestLayout()
  }
  
  /**
   * Set the margin between boxes
   */
  fun setBoxMargin(margin: Float) {
   boxMargin = dpToPx(margin)
   requestLayout()
  }
  
  /**
   * Set the box corner radius
   */
  fun setBoxCornerRadius(radius: Float) {
   boxCornerRadius = dpToPx(radius)
   invalidate()
  }
  
  /**
   * Set the box border width
   */
  fun setBoxBorderWidth(width: Float) {
   boxBorderWidth = dpToPx(width)
   invalidate()
  }
  
  /**
   * Set the box border color
   */
  fun setBoxBorderColor(color: Int) {
   boxBorderColor = color
   invalidate()
  }
  
  /**
   * Set the box focused border color
   */
  fun setBoxFocusedBorderColor(color: Int) {
   boxFocusedBorderColor = color
   invalidate()
  }
  
  /**
   * Set the box background color
   */
  fun setBoxBackgroundColor(color: Int) {
   boxBackgroundColor = color
   invalidate()
  }
  
  /**
   * Set the text color
   */
  fun setDigitTextColor(color: Int) {
   textColor = color
   for (digitBox in digitBoxes) {
    digitBox.setTextColor(color)
   }
  }
  
  /**
   * Set the text size
   */
  fun setDigitTextSize(size: Float) {
   textSize = spToPx(size)
   for (digitBox in digitBoxes) {
    digitBox.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
   }
  }
  
  /**
   * Convert dp to pixels
   */
  private fun dpToPx(dp: Float): Float {
   return TypedValue.applyDimension(
    TypedValue.COMPLEX_UNIT_DIP,
    dp,
    resources.displayMetrics
   )
  }
  
  /**
   * Convert sp to pixels
   */
  private fun spToPx(sp: Float): Float {
   return TypedValue.applyDimension(
    TypedValue.COMPLEX_UNIT_SP,
    sp,
    resources.displayMetrics
   )
  }
  
  /**
   * Custom EditText for digit input with custom drawing
   */
  private inner class DigitBox(context: Context) : androidx.appcompat.widget.AppCompatEditText(context) {
   
   private val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG)
   private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG)
   private val textBounds = Rect()
   
   init {
    borderPaint.style = Paint.Style.STROKE
    backgroundPaint.style = Paint.Style.FILL
   }
   
   override fun onDraw(canvas: Canvas) {
    val width = width.toFloat()
    val height = height.toFloat()
    
    // Draw background
    backgroundPaint.color = boxBackgroundColor
    canvas.drawRoundRect(0f, 0f, width, height, boxCornerRadius, boxCornerRadius, backgroundPaint)
    
    // Draw border
    borderPaint.color = when {
     isError -> boxErrorBorderColor
     isFocused -> boxFocusedBorderColor
     else -> boxBorderColor
    }
    borderPaint.strokeWidth = boxBorderWidth
    canvas.drawRoundRect(
     boxBorderWidth / 2,
     boxBorderWidth / 2,
     width - boxBorderWidth / 2,
     height - boxBorderWidth / 2,
     boxCornerRadius,
     boxCornerRadius,
     borderPaint
    )
    
    // Draw text centered
    val text = text.toString()
    if (text.isNotEmpty()) {
     val textPaint = paint
     textPaint.getTextBounds(text, 0, text.length, textBounds)
     val textX = (width - textBounds.width()) / 2
     val textY = height / 2 + textBounds.height() / 2
     canvas.drawText(text, textX, textY, textPaint)
    }
   }
  }
}

import android.os.Bundle
import android.widget.Button
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity

/**
 * Example activity showing how to use the VerificationCodeView
 */
class VerificationCodeActivity : AppCompatActivity() {
    
    private lateinit var verificationCodeView: VerificationCodeView
    private lateinit var clearButton: Button
    private lateinit var fillButton: Button
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        // Set up layout
        setContentView(R.layout.activity_verification_code)
        
        // Initialize views
        verificationCodeView = findViewById(R.id.verification_code_view)
        clearButton = findViewById(R.id.btn_clear)
        fillButton = findViewById(R.id.btn_fill)
        
        // Set up verification code completion listener
        verificationCodeView.setOnVerificationCodeCompleteListener(object : VerificationCodeView.OnVerificationCodeCompleteListener {
            override fun onVerificationCodeComplete(code: String) {
                Toast.makeText(this@VerificationCodeActivity, "Code entered: $code", Toast.LENGTH_SHORT).show()
                // Here you would typically validate the code with your backend
            }
        })
        
        // Set up clear button
        clearButton.setOnClickListener {
            verificationCodeView.clearCode()
        }
        
        // Set up fill button (for testing)
        fillButton.setOnClickListener {
            verificationCodeView.setCode("1234")
            verificationCodeView.showError()
        }
        
    }
}