You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Android Kotlin:不依赖第三方库实现自定义OTP输入视图

自定义实现Android OTP输入视图(无需第三方库/多EditText)

要实现这类OTP输入框,不用第三方库或多个EditText的核心思路是:基于单个EditText自定义View,通过重写onDraw()绘制出多分隔框的视觉效果,同时控制输入长度、字符定位和交互逻辑,既避免了多EditText的状态同步问题,又完全可控。

步骤1:定义自定义属性(可选,方便XML配置)

先在res/values/attrs.xml中定义可配置的属性,比如OTP位数、框大小、间距、颜色等:

<resources>
    <declare-styleable name="CustomOtpView">
        <attr name="otpCount" format="integer" />
        <attr name="otpBoxSize" format="dimension" />
        <attr name="otpBoxSpacing" format="dimension" />
        <attr name="otpBorderColor" format="color" />
        <attr name="otpBorderWidth" format="dimension" />
        <attr name="otpTextSize" format="dimension" />
        <attr name="otpTextColor" format="color" />
    </declare-styleable>
</resources>

步骤2:实现自定义OTP View

创建继承自AppCompatEditText的自定义View,完成初始化、绘制逻辑和交互处理:

public class CustomOtpView extends AppCompatEditText {
    private int otpCount = 6;
    private float boxSize;
    private float boxSpacing;
    private int borderColor;
    private float borderWidth;
    private Paint boxPaint;
    private Paint textPaint;
    private int cursorPosition = 0;

    public CustomOtpView(Context context) {
        super(context);
        init(null);
    }

    public CustomOtpView(Context context, AttributeSet attrs) {
        super(context, attrs);
        init(attrs);
    }

    public CustomOtpView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(attrs);
    }

    private void init(AttributeSet attrs) {
        // 读取自定义属性
        TypedArray ta = getContext().obtainStyledAttributes(attrs, R.styleable.CustomOtpView);
        otpCount = ta.getInt(R.styleable.CustomOtpView_otpCount, 6);
        boxSize = ta.getDimension(R.styleable.CustomOtpView_otpBoxSize, dpToPx(48));
        boxSpacing = ta.getDimension(R.styleable.CustomOtpView_otpBoxSpacing, dpToPx(12));
        borderColor = ta.getColor(R.styleable.CustomOtpView_otpBorderColor, Color.BLACK);
        borderWidth = ta.getDimension(R.styleable.CustomOtpView_otpBorderWidth, dpToPx(1));
        float textSize = ta.getDimension(R.styleable.CustomOtpView_otpTextSize, spToPx(16));
        int textColor = ta.getColor(R.styleable.CustomOtpView_otpTextColor, Color.BLACK);
        ta.recycle();

        // 配置EditText基础行为
        setInputType(InputType.TYPE_CLASS_NUMBER);
        setFilters(new InputFilter[]{new InputFilter.LengthFilter(otpCount)});
        setBackgroundColor(Color.TRANSPARENT);
        setCursorVisible(false); // 隐藏系统光标,自己绘制视觉光标
        setPadding(0, 0, 0, 0);

        // 初始化绘制画笔
        boxPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        boxPaint.setColor(borderColor);
        boxPaint.setStyle(Paint.Style.STROKE);
        boxPaint.setStrokeWidth(borderWidth);

        textPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        textPaint.setColor(textColor);
        textPaint.setTextSize(textSize);
        textPaint.setTextAlign(Paint.Align.CENTER);

        // 监听文本变化,更新光标位置
        addTextChangedListener(new TextWatcher() {
            @Override
            public void beforeTextChanged(CharSequence s, int start, int count, int after) {}

            @Override
            public void onTextChanged(CharSequence s, int start, int before, int count) {
                cursorPosition = s.length();
                invalidate(); // 触发重绘
            }

            @Override
            public void afterTextChanged(Editable s) {}
        });
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        // 计算整体宽度,居中绘制
        float totalWidth = otpCount * boxSize + (otpCount - 1) * boxSpacing;
        float startX = (getWidth() - totalWidth) / 2;
        float startY = (getHeight() - boxSize) / 2;

        // 循环绘制每个OTP框和字符
        for (int i = 0; i < otpCount; i++) {
            float left = startX + i * (boxSize + boxSpacing);
            float right = left + boxSize;
            float top = startY;
            float bottom = top + boxSize;

            // 高亮当前光标位置的框
            if (i == cursorPosition && hasFocus()) {
                boxPaint.setColor(Color.parseColor("#2196F3"));
                boxPaint.setStrokeWidth(borderWidth * 2);
            } else {
                boxPaint.setColor(borderColor);
                boxPaint.setStrokeWidth(borderWidth);
            }
            canvas.drawRect(left, top, right, bottom, boxPaint);

            // 绘制对应位置的字符
            if (i < getText().length()) {
                String charStr = String.valueOf(getText().charAt(i));
                // 垂直居中计算文本Y坐标
                float textY = startY + boxSize / 2 - (textPaint.descent() + textPaint.ascent()) / 2;
                canvas.drawText(charStr, left + boxSize / 2, textY, textPaint);
            }
        }
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        // 自定义View的宽高计算
        float totalWidth = otpCount * boxSize + (otpCount - 1) * boxSpacing;
        int width = (int) totalWidth + getPaddingLeft() + getPaddingRight();
        int height = (int) boxSize + getPaddingTop() + getPaddingBottom();
        setMeasuredDimension(width, height);
    }

    // 处理焦点变化,自动弹出键盘
    @Override
    protected void onFocusChanged(boolean focused, int direction, Rect previouslyFocusedRect) {
        super.onFocusChanged(focused, direction, previouslyFocusedRect);
        if (focused) {
            InputMethodManager imm = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
            imm.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT);
        }
    }

    // 优化删除键行为
    @Override
    public boolean onKeyDown(int keyCode, KeyEvent event) {
        if (keyCode == KeyEvent.KEYCODE_DEL && getText().length() == 0) {
            // 文本为空时,拦截删除键,避免光标错位
            return true;
        }
        return super.onKeyDown(keyCode, event);
    }

    // 工具方法:dp转px
    private float dpToPx(float dp) {
        return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, getResources().getDisplayMetrics());
    }

    // 工具方法:sp转px
    private float spToPx(float sp) {
        return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, sp, getResources().getDisplayMetrics());
    }
}

步骤3:在布局中使用自定义View

直接在XML布局中引用,可通过自定义属性配置样式:

<com.yourpackage.name.CustomOtpView
    android:id="@+id/otpView"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_gravity="center"
    app:otpCount="6"
    app:otpBoxSize="48dp"
    app:otpBoxSpacing="12dp"
    app:otpBorderColor="@color/black"
    app:otpBorderWidth="1dp"
    app:otpTextSize="18sp"
    app:otpTextColor="@color/black" />

可选优化点

  • 密码模式:将绘制的字符替换为●,只需修改onDraw()中绘制文本的逻辑:
    String charStr = "●"; // 替换原字符
    
  • 错误状态:添加setErrorState(boolean isError)方法,修改边框颜色并触发重绘:
    public void setErrorState(boolean isError) {
        borderColor = isError ? Color.RED : Color.BLACK;
        invalidate();
    }
    
  • 横竖屏状态保存:重写onSaveInstanceState和onRestoreInstanceState,保存光标位置和文本状态(EditText默认会保存文本,自定义状态需额外处理)。

内容的提问来源于stack exchange,提问作者Nikitha

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.04 14:40:31