自定义数字输入View

作者: Jaesoon | 来源:发表于2018-06-13 20:50 被阅读180次

    有一个场景,需要输入短信验证码。So,尝试着自己设计了一个这样的View。参考了一些App,发现建设银行手机银行的短信验证码界面是我想要的。所以,设计了如下图这样两个短信输入框原型。


    害羞

    本页图稍微有点大,可能要加载一会儿。

    两种短信验证码原型图

    再看一个最终的效果图。


    效果图

    特点

    随输入的字符产生动画效果(如上图)
    额,当然,图有点糊了,看的不是很清楚。
    分两个场景,输入和删除

    输入

    当用户输入一个数字的时大概有两个效果:

    1. 文字alpha由全透明变成不透明
    2. 指示底线从中间向两边发生颜色渐变

    删除

    当用户删除一个数字的时大概有两个效果:

    1. 文字alpha由不透明变成透明(消失)
    2. 指示底线从两边向中间发生颜色渐变

    如何实现?

    写代码重要的是分解。所以,看上面的原型,我们可以这样分解:一个ViewGroup承载着几个View。ViewGroup水平布局着这些VIew。每一个View在显示和消失时,会有一个动画。如果这样分解的话,我们就很清楚了如何来实现这个效果了。
    Show you the code. 代码由一个ViewGroup和一个View构成。
    ①. SingleNumberView(View)

    import android.content.Context;
    import android.content.res.Resources;
    import android.graphics.Canvas;
    import android.graphics.Color;
    import android.graphics.Paint;
    import android.graphics.Rect;
    import android.support.annotation.Nullable;
    import android.text.TextUtils;
    import android.util.AttributeSet;
    import android.view.View;
    import android.view.animation.Animation;
    import android.view.animation.Transformation;
    
    /**
     * 功能说明:<br>
     * <ul>
     * <li>当用户输入文字之后,产生两个动画:
     * <ol>
     * <li>文字透明度变化:文字透明度由透明度100%到0%</li>
     * <li>底部标线颜色变化:底部标线激活颜色由水平中心扩展到两端</li>
     * </ol>
     * </li>
     * <p>
     * <li>当用户清除了文字之后,产生两个动画:
     * <ol>
     * <li>文字透明度变化:文字透明度由透明度0%到100%</li>
     * <li>底部标线颜色变化:底部标线激活颜色由两端收缩到中心,然后不可见</li>
     * </ol>
     * </li>
     * </ul>
     */
    public class SingleNumberView extends View {
        private static final String TAG = SingleNumberView.class.getSimpleName();
        /**
         * 相关动画:文字颜色动画、底部标线动画
         */
        private Animation lineExpenseAnimation;
        private Animation lineShrinkAnimation;
    
        /**
         * 动画周期 单位:ms
         */
        private int mDuration = 500;
    
        /**
         * 动画百分比(不是动画消逝时间百分比) InterpolatorFraction
         */
        private float mInterpolatorFraction = 0;
    
        /**
         * 当前数字
         */
        private String mNumber = "";
        /**
         * 文本颜色
         */
        private int textColor = Color.BLACK;
        /**
         * 文本字体大小
         */
        private int textSize = (int) (Resources.getSystem().getDisplayMetrics().density * 25);
        /**
         * 文本为空底部文字颜色
         */
        private int mBottomLineEmptyColor = Color.parseColor("#47b4db");
    
        /**
         * 文本为激活状态文字颜色
         */
        private int mBottomLineActiveColor = Color.parseColor("#6ae1ff");
    
        /**
         * 底部线的宽窄
         */
        private int mBottomLineWidth = (int) (Resources.getSystem().getDisplayMetrics().density * 1.5);
    
        /**
         * 文本画笔
         */
        private Paint mTextPaint;
    
        /**
         * 标线画笔
         */
        private Paint mBottomLinePaint;
    
        public SingleNumberView(Context context) {
            super(context);
            init();
        }
    
        public SingleNumberView(Context context, @Nullable AttributeSet attrs) {
            super(context, attrs);
            init();
        }
    
        public void init() {
            //初始化动画对象
            lineExpenseAnimation = new Animation() {
                @Override
                protected void applyTransformation(float interpolatedTime, Transformation t) {
                    super.applyTransformation(interpolatedTime, t);
                    ensureInterpolator();
                    mInterpolatorFraction = getInterpolator().getInterpolation(interpolatedTime);
    //                Log.e("SingleNumberView", mInterpolatorFraction + " .");
                    mTextPaint.setAlpha((int) (mInterpolatorFraction * 255));
                    invalidate();
                }
            };
            lineExpenseAnimation.setDuration(mDuration);
    
    
            lineShrinkAnimation = new Animation() {
                @Override
                protected void applyTransformation(float interpolatedTime, Transformation t) {
                    super.applyTransformation(interpolatedTime, t);
                    ensureInterpolator();
                    mInterpolatorFraction = getInterpolator().getInterpolation(1 - interpolatedTime);
    //                Log.e("SingleNumberView", mInterpolatorFraction + " ;");
                    mTextPaint.setAlpha((int) (mInterpolatorFraction * 255));
                    invalidate();
                }
            };
            lineShrinkAnimation.setDuration(mDuration);
    
            lineShrinkAnimation.setAnimationListener(new Animation.AnimationListener() {
                @Override
                public void onAnimationStart(Animation animation) {
    
                }
    
                @Override
                public void onAnimationEnd(Animation animation) {
    //                mNumber = "";
                }
    
                @Override
                public void onAnimationRepeat(Animation animation) {
    
                }
            });
    
            //初始化画笔
            mTextPaint = new Paint();
            mTextPaint.setAntiAlias(true);
            mTextPaint.setTextSize(textSize);
            mTextPaint.setColor(textColor);
    
            mBottomLinePaint = new Paint();
            mBottomLinePaint.setStrokeCap(Paint.Cap.ROUND);
            mBottomLinePaint.setStrokeWidth(mBottomLineWidth);
        }
    
        /**
         * 开始绘制
         */
        public void onDraw(Canvas canvas) {
            //开始绘制文字
            if (!TextUtils.isEmpty(mNumber)) {
                //绘制文字
                //仔细推导一下,就会找到合适的居中工具(可参考引文书写四线三格)
                int baseline = getTextBaseline(getPaddingTop());
                canvas.drawText(mNumber, getPaddingLeft() + mTextPaint.measureText("8") / 4, baseline, mTextPaint);
            } else {
                //不需要绘制文字
            }
            //开始绘制底部基础线框
            int lineY = (int) (getMeasuredHeight() - mBottomLinePaint.getStrokeWidth() - getPaddingBottom());
            int lineLength = getMeasuredWidth() - getPaddingLeft() - getPaddingRight();
            int lineStart = getPaddingLeft();
            mBottomLinePaint.setColor(mBottomLineEmptyColor);
            canvas.drawLine(lineStart,
                    lineY,
                    lineStart + lineLength,
                    lineY, mBottomLinePaint);
    
            //开始绘制底部激活线框
            mBottomLinePaint.setColor(mBottomLineActiveColor);
            lineLength = (int) (mInterpolatorFraction * (getMeasuredWidth() - getPaddingLeft() - getPaddingRight()));
            lineStart = (getMeasuredWidth() - getPaddingLeft() - getPaddingRight() - lineLength) / 2 + getPaddingLeft();
            if (lineLength > 0f && lineStart > 0f) {
                canvas.drawLine(lineStart,
                        lineY,
                        lineStart + lineLength,
                        lineY, mBottomLinePaint);
            }
        }
    
        private int getTextBaseline(int top) {
            Rect bounds = new Rect();
            mTextPaint.getTextBounds(mNumber, 0, mNumber.length(), bounds);
            Paint.FontMetricsInt fontMetrics = mTextPaint.getFontMetricsInt();
            int center = top + bounds.height() / 2;
            int baseline = center + (fontMetrics.bottom - fontMetrics.top) / 2 - fontMetrics.bottom;
    //        Log.e(TAG, "baseline = " + baseline);
            return baseline;
        }
    
        public void setTextColor(int textColor) {
            this.textColor = textColor;
            mTextPaint.setColor(textColor);
        }
    
        public void setTextSize(int textSize) {
            this.textSize = textSize;
            mTextPaint.setTextSize(textSize);
        }
    
        public void setActiveColor(int color) {
            mBottomLineActiveColor = color;
        }
    
        public void setInactiveColor(int color) {
            mBottomLineEmptyColor = color;
        }
    
        public void setBottomLineWidth(int width) {
            mBottomLineWidth = width;
            mBottomLinePaint.setStrokeWidth(mBottomLineWidth);
        }
    
        @Override
        protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
            super.onMeasure(widthMeasureSpec, heightMeasureSpec);
            int measureWidth = measureWidth(widthMeasureSpec);
            int measureHeight = measureHeight(heightMeasureSpec);
            setMeasuredDimension(measureWidth, measureHeight);
        }
    
        private int measureWidth(int pWidthMeasureSpec) {
            int result = 0;
            int widthMode = MeasureSpec.getMode(pWidthMeasureSpec);// 得到模式
            int widthSize = MeasureSpec.getSize(pWidthMeasureSpec);// 得到尺寸
    
            switch (widthMode) {
                case MeasureSpec.AT_MOST:
                case MeasureSpec.UNSPECIFIED:
    //                Log.e(TAG, "我被测量啦,width :" + getPaddingLeft() + "|" + getPaddingRight());
                    result = (int) (mTextPaint.measureText("8") * 1.5f + getPaddingLeft() + getPaddingRight());
                    break;
                case MeasureSpec.EXACTLY:
                    // match_parent或具体的值如:60dp
                    result = widthSize;
                    break;
            }
            return result;
        }
    
        private int measureHeight(int pHeightMeasureSpec) {
            int result = 0;
    
            int heightMode = MeasureSpec.getMode(pHeightMeasureSpec);
            int heightSize = MeasureSpec.getSize(pHeightMeasureSpec);
    
            switch (heightMode) {
                case MeasureSpec.AT_MOST:
                case MeasureSpec.UNSPECIFIED:
    //                Log.e(TAG, "我被测量啦,height :" + getPaddingTop() + "|" + getPaddingBottom());
                    Rect bounds = new Rect();
                    mTextPaint.getTextBounds("8", 0, 1, bounds);
                    result = bounds.height() + getPaddingTop() + getPaddingBottom();
                    //线宽
                    result += mBottomLinePaint.getStrokeWidth();
                    //这个是文字与下划线的间隔
                    result += getPaddingBottom();
                    break;
                case MeasureSpec.EXACTLY:
                    // match_parent或具体的值如:60dp
                    result = heightSize;
                    break;
            }
            return result;
        }
    
        public void setNumber(String mNumber) {
            if (lineShrinkAnimation != null) {
                lineShrinkAnimation.cancel();
            }
            if (lineExpenseAnimation != null) {
                lineExpenseAnimation.cancel();
            }
            if (TextUtils.isEmpty(mNumber)) {
                startAnimation(lineShrinkAnimation);
            } else {
                this.mNumber = mNumber;
                startAnimation(lineExpenseAnimation);
            }
        }
    }
    

    在这里插一句,一般我分析一个自定义View,首先会看构造函数,然后是onMeasure方法,在来onLayout方法,最后是onDraw方法。如果这个自定义View还定义了复杂的手势交互,可能还需要看onTouchEvent。如果是ViewGroup可能还需要看看onInterceptTouchEvent。当然,也需要看看这个View是否支持嵌套滑动。以上就是套路。

    构造函数

    按照上面的套路,我们首先看看构造函数。总共重写了两个构造函数。关于这两个构造函数分别是在什么时间调用,请自己百度,不在此搬运别人的分析了。共同点是,两个构造函数都调用了一个共同的函数 - init()。让我们看看在这个方法中做了什么。

    public void init() {
            //初始化动画对象
            lineExpenseAnimation = new Animation() {
                @Override
                protected void applyTransformation(float interpolatedTime, Transformation t) {
                    super.applyTransformation(interpolatedTime, t);
                    ensureInterpolator();
                    mInterpolatorFraction = getInterpolator().getInterpolation(interpolatedTime);
    //                Log.e("SingleNumberView", mInterpolatorFraction + " .");
                    mTextPaint.setAlpha((int) (mInterpolatorFraction * 255));
                    invalidate();
                }
            };
            lineExpenseAnimation.setDuration(mDuration);
    
    
            lineShrinkAnimation = new Animation() {
                @Override
                protected void applyTransformation(float interpolatedTime, Transformation t) {
                    super.applyTransformation(interpolatedTime, t);
                    ensureInterpolator();
                    mInterpolatorFraction = getInterpolator().getInterpolation(1 - interpolatedTime);
    //                Log.e("SingleNumberView", mInterpolatorFraction + " ;");
                    mTextPaint.setAlpha((int) (mInterpolatorFraction * 255));
                    invalidate();
                }
            };
            lineShrinkAnimation.setDuration(mDuration);
    
            lineShrinkAnimation.setAnimationListener(new Animation.AnimationListener() {
                @Override
                public void onAnimationStart(Animation animation) {
    
                }
    
                @Override
                public void onAnimationEnd(Animation animation) {
    //                mNumber = "";
                }
    
                @Override
                public void onAnimationRepeat(Animation animation) {
    
                }
            });
    
            //初始化画笔
            mTextPaint = new Paint();
            mTextPaint.setAntiAlias(true);
            mTextPaint.setTextSize(textSize);
            mTextPaint.setColor(textColor);
    
            mBottomLinePaint = new Paint();
            mBottomLinePaint.setStrokeCap(Paint.Cap.ROUND);
            mBottomLinePaint.setStrokeWidth(mBottomLineWidth);
        }
    

    共创建了两个动画,分别完成我们在原型中设计的动效:
    输入
    当用户输入一个数字的时有两个动画效果:

    1. 文字alpha由全透明变成不透明
    2. 指示底线从中间向两边发生颜色渐变

    删除
    当用户删除一个数字的时有两个动画效果:

    1. 文字alpha由不透明变成透明(消失)
    2. 指示底线从两边向中间发生颜色渐变

    两个动画的 applyTransformation 方法中,根据动画消逝的时间比例,计算出mInterpolatorFraction。mInterpolatorFraction是完成动画的关键因数,所有的动画效果它有关系。如,在这个方法中,紧接着就根据这个因数,设置了文字画笔mTextPaint的alpha。
    此外,在init方法中,还创建了两个画笔,分别绘制数字和底部划线。

    onMeasure方法

    这个方法的作用是在系统绘制你的自定义View之前,先测量View的大小。如何理解?就像是我们在给墙壁贴壁纸时,首先要知道墙壁以及每一张壁纸的尺寸。我们就相当于是Android系统,墙壁就是我们的View所在的ViewGroup,View当然就相当于壁纸。在给墙壁贴壁纸之前,首先会测量墙壁和壁纸的尺寸(measure),然后布局(layout),最后贴图(draw)。同样绘制前,ViewGroup会调用我们的View的measure方法,让自定义View测量自己。你可能会说:啥?我读书少,你可不要骗我,我分明没有看到那你重写这个方法。对,你的思维很活跃。但是深度不够。如果你足够仔细的话,可以看到我们的自定义View是继承于android.view.View的。你再阅读以下View的源码,会发现,measure方法是final的,我们是无法继承的。但是,看不到,并不代表没有。在measure方法中,调用了onMeasure方法。扯了一大堆,让我们看看代码。

     @Override
        protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
            super.onMeasure(widthMeasureSpec, heightMeasureSpec);
            int measureWidth = measureWidth(widthMeasureSpec);
            int measureHeight = measureHeight(heightMeasureSpec);
            setMeasuredDimension(measureWidth, measureHeight);
        }
    
        private int measureWidth(int pWidthMeasureSpec) {
            int result = 0;
            int widthMode = MeasureSpec.getMode(pWidthMeasureSpec);// 得到模式
            int widthSize = MeasureSpec.getSize(pWidthMeasureSpec);// 得到尺寸
    
            switch (widthMode) {
                case MeasureSpec.AT_MOST:
                case MeasureSpec.UNSPECIFIED:
    //              Log.e(TAG, "我被测量啦,width :" + getPaddingLeft() + "|" + getPaddingRight());
                    result = (int) (mTextPaint.measureText("8") * 1.5f + getPaddingLeft() + getPaddingRight());
                    break;
                case MeasureSpec.EXACTLY:
                    // match_parent或具体的值如:60dp
                    result = widthSize;
                    break;
            }
            return result;
        }
    
        private int measureHeight(int pHeightMeasureSpec) {
            int result = 0;
    
            int heightMode = MeasureSpec.getMode(pHeightMeasureSpec);
            int heightSize = MeasureSpec.getSize(pHeightMeasureSpec);
    
            switch (heightMode) {
                case MeasureSpec.AT_MOST:
                case MeasureSpec.UNSPECIFIED:
    //                Log.e(TAG, "我被测量啦,height :" + getPaddingTop() + "|" + getPaddingBottom());
                    Rect bounds = new Rect();
                    mTextPaint.getTextBounds("8", 0, 1, bounds);
                    result = bounds.height() + getPaddingTop() + getPaddingBottom();
                    //线宽
                    result += mBottomLinePaint.getStrokeWidth();
                    //这个是文字与下划线的间隔
                    result += getPaddingBottom();
                    break;
                case MeasureSpec.EXACTLY:
                    // match_parent或具体的值如:60dp
                    result = heightSize;
                    break;
            }
            return result;
        }
    

    onMeasure(int widthMeasureSpec, int heightMeasureSpec)方法有两个入参,分别是宽和高。每一个MeasureSpec方法都包含两个信息,模式和尺寸。我们可以通过MeasureSpec.getMode和MeasureSpec.getSize两个方法获取。
    有三种模式,分别是:UNSPECIFIED、EXACTLY和AT_MOST:

    • UNSPECIFIED:说明父ViewGroup没有对子View强加任何限制,子View可以是它想要的任何尺寸。用得比较少,表示子布局被限制在一个最大值内,一般当childView设置其宽、高为wrap_content时,ViewGroup会将其设置为AT_MOST,换言之,表示子布局想要多大就多大。一般出现在可以滑动的ViewGroup,很好理解,屏幕不可能无限大,既然又能支持子View想要多少就能得到多少,当然是通过滑动来实现的。如AadapterView的item的heightMode中、ScrollView的childView的heightMode中

    • EXACTLY:父ViewGroup为子View决定了一个确切的尺寸,子View将会被强制赋予这些边界限制,不管子View自己想要多大(View类onMeasure方法中只支持EXACTLY),换言之,表示设置了精确的值,一般当childView在xml或代码中设置其宽、高为精确值、match_parent时,ViewGroup会将其设置为EXACTLY,即在布局文件代码中可以解析指定的具体尺寸和match_parent。

    • AT_MOST:子View可以是自己指定的任意大小,但是有个上限。比如说当MeasureSpec.EXACTLY的父容器为子级决定了一个大小,子级大小只能在这个父容器限制的范围之内。即在布局文件中可以解析wrap_content,换言之,表示子布局被限制在一个最大值内,一般当childView设置其宽、高为wrap_content时,ViewGroup会将其设置为AT_MOST。

    可以看到,在 measureWidth 方法中,我们首先判断了模式,然后根据不同的模式,给出自己的宽度值。如果是EXACTLY模式,我们就按照给定的值,给出自己的宽度。如果是UNSPECIFIED或AT_MOST模式,就设置一个数字“8”的宽度1.5倍加上左右的padding。关于高度的测量,我就不解释了,逻辑类似。

    onLayout

    作为一个View,就没有必要重写这方法了。

    onDraw

    这个是视图显示的核心部分了。

        /**
         * 开始绘制
         */
        public void onDraw(Canvas canvas) {
            //开始绘制文字
            if (!TextUtils.isEmpty(mNumber)) {
                //绘制文字
                //仔细推导一下,就会找到合适的居中工具(可参考引文书写四线三格)
                int baseline = getTextBaseline(getPaddingTop());
                canvas.drawText(mNumber, getPaddingLeft() + mTextPaint.measureText("8") / 4, baseline, mTextPaint);
            } else {
                //不需要绘制文字
            }
            //开始绘制底部基础线框
            int lineY = (int) (getMeasuredHeight() - mBottomLinePaint.getStrokeWidth() - getPaddingBottom());
            int lineLength = getMeasuredWidth() - getPaddingLeft() - getPaddingRight();
            int lineStart = getPaddingLeft();
            mBottomLinePaint.setColor(mBottomLineEmptyColor);
            canvas.drawLine(lineStart,
                    lineY,
                    lineStart + lineLength,
                    lineY, mBottomLinePaint);
    
            //开始绘制底部激活线框
            mBottomLinePaint.setColor(mBottomLineActiveColor);
            lineLength = (int) (mInterpolatorFraction * (getMeasuredWidth() - getPaddingLeft() - getPaddingRight()));
            lineStart = (getMeasuredWidth() - getPaddingLeft() - getPaddingRight() - lineLength) / 2 + getPaddingLeft();
            if (lineLength > 0f && lineStart > 0f) {
                canvas.drawLine(lineStart,
                        lineY,
                        lineStart + lineLength,
                        lineY, mBottomLinePaint);
            }
        }
    

    代码这么短,你是不是很失望?ha ha ha,浓缩才能成为精华。
    这个方法,其实就做了两件事:

    • 绘制文字
    • 绘制底部标线
      • 基础标线
      • 激活标线

    使用了canvas一些常见的方法,很简单。没有用过的同学,可以查看API Reference
    看到这个方法,你是否还在困惑动画是如何实现的呢?请注意一下,我们刚刚在将构造函数时,提到了init方法中的 mInterpolatorFraction 变量。这个变量一直被动画改变,在这个变量被改变之后,invalidate 方法接着被调用,地球人都知道的是:这个方法会导致View重新绘制。这意味着onDraw方法接着会被调用。而我们在绘制底部激活线时,又是根据 mInterpolatorFraction 来控制线的长短。就这样,产生了动画。简单不简单,可爱不可爱。

    我不管,我最可爱

    ②. NumberInputView(ViewGroup)

    import android.content.Context;
    import android.content.res.Resources;
    import android.content.res.TypedArray;
    import android.graphics.Color;
    import android.graphics.Rect;
    import android.support.annotation.Nullable;
    import android.text.InputType;
    import android.util.AttributeSet;
    import android.util.Log;
    import android.view.KeyEvent;
    import android.view.MotionEvent;
    import android.view.View;
    import android.view.inputmethod.BaseInputConnection;
    import android.view.inputmethod.EditorInfo;
    import android.view.inputmethod.InputConnection;
    import android.view.inputmethod.InputMethodManager;
    import android.widget.LinearLayout;
    
    import com.jaesoon.messageverifydemo.R;
    
    import java.util.ArrayList;
    
    public class NumberInputView extends LinearLayout {
        private String TAG = "NumberInputView";
        private InputMethodManager input;//输入法管理
        private ArrayList<Integer> result;//输入结果保存
        private int digit = 6;//密码位数
        private int mActiveColor = Color.parseColor("#6ae1ff");
        private int mInactiveColor = Color.parseColor("#47b4db");
        private int mTextColor = Color.parseColor("#000000");
        private int mTextSize = (int) (Resources.getSystem().getDisplayMetrics().density * 25);
        private int mSpacing = (int) (Resources.getSystem().getDisplayMetrics().density * 4);
        private int mBottomLineWidth = (int) (Resources.getSystem().getDisplayMetrics().density * 1.5);
    
        public NumberInputView(Context context) {
            super(context);
            init(context, null);
        }
    
        public NumberInputView(Context context, @Nullable AttributeSet attrs) {
            super(context, attrs);
            init(context, attrs);
        }
    
        private void init(Context context, @Nullable AttributeSet attrs) {
            this.setFocusable(true);
            this.setFocusableInTouchMode(true);
            clearFocus();
            input = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
            result = new ArrayList<>();
    
            TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.NumberInputView);
            int n = a.getIndexCount();
            for (int i = 0; i < n; i++) {
                int attr = a.getIndex(i);
                switch (attr) {
                    case R.styleable.NumberInputView_activeColor:
                        mActiveColor = a.getColor(attr, mActiveColor);
                        break;
                    case R.styleable.NumberInputView_inactiveColor:
                        mInactiveColor = a.getColor(attr, mInactiveColor);
                        break;
                    case R.styleable.NumberInputView_numberColor:
                        mTextColor = a.getColor(attr, mTextColor);
                        break;
                    case R.styleable.NumberInputView_numberTextSize:
                        mTextSize = a.getDimensionPixelSize(attr, mTextSize);
                        break;
                    case R.styleable.NumberInputView_spacing:
                        mSpacing = a.getDimensionPixelSize(attr, mSpacing);
                        break;
                    case R.styleable.NumberInputView_bottomLineWidth:
                        mBottomLineWidth = a.getDimensionPixelSize(attr, mBottomLineWidth);
                        break;
                    case R.styleable.NumberInputView_digit:
                        digit = a.getInt(attr, digit);
                        break;
                }
            }
        }
    
        @Override
        protected void onAttachedToWindow() {
            super.onAttachedToWindow();
            if (getChildCount() <= 0) {
                for (int i = 0; i < 6; i++) {
                    SingleNumberView singleNumberView = new SingleNumberView(getContext(), null);
                    singleNumberView.setPadding(mSpacing / 2, mSpacing / 2, mSpacing / 2, mSpacing);
                    singleNumberView.setTextColor(mTextColor);
                    singleNumberView.setTextSize(mTextSize);
                    singleNumberView.setActiveColor(mActiveColor);
                    singleNumberView.setInactiveColor(mInactiveColor);
                    singleNumberView.setBottomLineWidth(mBottomLineWidth);
                    LinearLayout.LayoutParams layoutParams = new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
                    singleNumberView.setLayoutParams(layoutParams);
                    addView(singleNumberView);
                }
            }
        }
    
        @Override
        public boolean onTouchEvent(MotionEvent event) {
            if (event.getAction() == MotionEvent.ACTION_DOWN) {//点击控件弹出输入键盘
                requestFocus();
                input.showSoftInput(this, InputMethodManager.SHOW_FORCED);
                return true;
            }
            return super.onTouchEvent(event);
        }
    
        @Override
        protected void onFocusChanged(boolean gainFocus, int direction, Rect previouslyFocusedRect) {
            super.onFocusChanged(gainFocus, direction, previouslyFocusedRect);
            if (gainFocus) {
                input.showSoftInput(this, InputMethodManager.SHOW_FORCED);
            } else {
                input.hideSoftInputFromInputMethod(this.getWindowToken(), 0);
            }
        }
    
        @Override
        public void onWindowFocusChanged(boolean hasWindowFocus) {
            super.onWindowFocusChanged(hasWindowFocus);
            if (!hasWindowFocus) {
                input.hideSoftInputFromWindow(this.getWindowToken(), 0);
            }
        }
    
        public String getText() {
            StringBuffer sb = new StringBuffer();
            for (int i : result) {
                sb.append(i);
            }
            return sb.toString();
        }
    
        private InputCallBack inputCallBack;//输入完成的回调
    
        public interface InputCallBack {
            void onInputFinish(String result);
        }
    
        public void setInputCallBack(InputCallBack inputCallBack) {
            this.inputCallBack = inputCallBack;
        }
    
        @Override
        public boolean onCheckIsTextEditor() {
            return true;
        }
    
        @Override
        public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
            outAttrs.inputType = InputType.TYPE_CLASS_NUMBER;//输入类型为数字
            outAttrs.imeOptions = EditorInfo.IME_ACTION_DONE;
            return new JInputConnection(this, false);
        }
    
        class JInputConnection extends BaseInputConnection {
    
            public JInputConnection(View targetView, boolean fullEditor) {
                super(targetView, fullEditor);
            }
    
            @Override
            public boolean commitText(CharSequence text, int newCursorPosition) {
                //这里是接受输入法的文本的,我们只处理数字,所以什么操作都不做
                return super.commitText(text, newCursorPosition);
            }
    
            @Override
            public boolean sendKeyEvent(KeyEvent event) {
                if (event.getAction() == KeyEvent.ACTION_DOWN) {
                    Log.e(TAG, event.getKeyCode() + "");
                    if (event.isShiftPressed()) {//处理*#等键
                        return false;
                    }
                    int keyCode = event.getKeyCode();
                    if (keyCode >= KeyEvent.KEYCODE_0 && keyCode <= KeyEvent.KEYCODE_9) {//只处理数字
                        if (result.size() < digit) {
                            result.add(keyCode - KeyEvent.KEYCODE_0);
                            if (getChildAt(result.size() - 1) instanceof SingleNumberView) {
                                Log.e(TAG, keyCode + ";");
                                ((SingleNumberView) getChildAt(result.size() - 1)).setNumber(result.get(result.size() - 1) + "");
                            }
                            ensureFinishInput();
                        }
                        return true;
                    }
                    if (keyCode == KeyEvent.KEYCODE_DEL) {
                        if (!result.isEmpty()) {//不为空,删除最后一个
                            result.remove(result.size() - 1);
                            if (getChildAt(result.size()) instanceof SingleNumberView) {
                                ((SingleNumberView) getChildAt(result.size())).setNumber("");
                            }
                        }
                        return true;
                    }
                    if (keyCode == KeyEvent.KEYCODE_ENTER) {
                        ensureFinishInput();
                        return true;
                    }
                }
                return super.sendKeyEvent(event);
            }
    
            @Override
            public boolean deleteSurroundingText(int beforeLength, int afterLength) {
                //软键盘的删除键 DEL 无法直接监听,自己发送del事件
                if (beforeLength == 1 && afterLength == 0) {
                    return super.sendKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL))
                            && super.sendKeyEvent(new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DEL));
                }
                return super.deleteSurroundingText(beforeLength, afterLength);
            }
        }
    
        /**
         * 判断是否输入完成,输入完成后调用callback
         */
        void ensureFinishInput() {
            if (result.size() == digit) {//输入完成
                if (inputCallBack != null) {
                    StringBuffer sb = new StringBuffer();
                    for (int i : result) {
                        sb.append(i);
                    }
                    inputCallBack.onInputFinish(sb.toString());
                }
            }
        }
    }
    

    不要被它的名字迷惑,其实它是个ViewGroup。它是LinearLayout的子类。为什么要用LinearLayout?因为我们上面有分解过原型。我们需要一个水平排列View的ViewGroup。所以用LinearLayout最好不过了。因为我们不仅要布局,还要支持键盘输入和自定义各种属性,所以,我们不能直接使用LinearLayout,要自定义一个LinearLayout的子类。

    构造函数

    同样,我们先分析构造函数。在重写的两个构造函数中,都调用了init函数。我们分析下这个函数:

    private void init(Context context, @Nullable AttributeSet attrs) {
            this.setFocusable(true);
            this.setFocusableInTouchMode(true);
            clearFocus();
            input = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
            result = new ArrayList<>();
    
            TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.NumberInputView);
            int n = a.getIndexCount();
            for (int i = 0; i < n; i++) {
                int attr = a.getIndex(i);
                switch (attr) {
                    case R.styleable.NumberInputView_activeColor:
                        mActiveColor = a.getColor(attr, mActiveColor);
                        break;
                    case R.styleable.NumberInputView_inactiveColor:
                        mInactiveColor = a.getColor(attr, mInactiveColor);
                        break;
                    case R.styleable.NumberInputView_numberColor:
                        mTextColor = a.getColor(attr, mTextColor);
                        break;
                    case R.styleable.NumberInputView_numberTextSize:
                        mTextSize = a.getDimensionPixelSize(attr, mTextSize);
                        break;
                    case R.styleable.NumberInputView_spacing:
                        mSpacing = a.getDimensionPixelSize(attr, mSpacing);
                        break;
                    case R.styleable.NumberInputView_bottomLineWidth:
                        mBottomLineWidth = a.getDimensionPixelSize(attr, mBottomLineWidth);
                        break;
                    case R.styleable.NumberInputView_digit:
                        digit = a.getInt(attr, digit);
                        break;
                }
            }
        }
    

    首先,我们先设置支持键盘输入:设置可以聚焦聚焦和获取了输入法管理器。然后就是支持个性化了。分析需求,我们可以知道,有这些需要个性化:底部激活线的颜色、底部基线的颜色、数字的颜色、文字的尺寸大小、文字之间的间隔、底线的宽度和接收输入的数字的位数(四位或六位短信验证码,或者更多位数)。因为,我们直接借用了LinearLayout的布局原理,所以,就没有重写onMeasure和onLayout方法。这里就不分析了。接下来我们看看如何实现支持个性化和键盘输入。

    支持个性化

    首先,我们根据需求,定义了一个xml文档。

    <?xml version="1.0" encoding="utf-8"?>
    <resources>
        <declare-styleable name="NumberInputView">
            <attr name="activeColor" format="color" />
            <attr name="inactiveColor" format="color" />
            <attr name="numberColor" format="color" />
            <attr name="numberTextSize" format="dimension" />
            <attr name="spacing" format="dimension" />
            <attr name="bottomLineWidth" format="dimension" />
            <attr name="digit" format="integer" />
        </declare-styleable>
    </resources>
    

    这样,我们就可以在layout文件中个性化定义各种特性。

       <com.jaesoon.messageverifydemo.widget.NumberInputView
            xmlns:app="http://schemas.android.com/apk/res-auto"
            android:id="@+id/numberInputView"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center"
            android:layout_marginTop="25dp"
            android:orientation="horizontal"
            android:padding="0dp" 
            app:activeColor="@color/red"
            />
    

    这样,在我们的init方法中,就可以获取到activeColor。

            TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.NumberInputView);
            int n = a.getIndexCount();
            for (int i = 0; i < n; i++) {
                int attr = a.getIndex(i);
                switch (attr) {
                    case R.styleable.NumberInputView_activeColor:
                        mActiveColor = a.getColor(attr, mActiveColor);
                        break;
                    case R.styleable.NumberInputView_inactiveColor:
                        mInactiveColor = a.getColor(attr, mInactiveColor);
                        break;
                    case R.styleable.NumberInputView_numberColor:
                        mTextColor = a.getColor(attr, mTextColor);
                        break;
                    case R.styleable.NumberInputView_numberTextSize:
                        mTextSize = a.getDimensionPixelSize(attr, mTextSize);
                        break;
                    case R.styleable.NumberInputView_spacing:
                        mSpacing = a.getDimensionPixelSize(attr, mSpacing);
                        break;
                    case R.styleable.NumberInputView_bottomLineWidth:
                        mBottomLineWidth = a.getDimensionPixelSize(attr, mBottomLineWidth);
                        break;
                    case R.styleable.NumberInputView_digit:
                        digit = a.getInt(attr, digit);
                        break;
                }
            }
    

    支持键盘输入

    这一部分,稍微有点麻烦。先看代码。

        @Override
        public boolean onCheckIsTextEditor() {
            return true;
        }
    
        @Override
        public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
            outAttrs.inputType = InputType.TYPE_CLASS_NUMBER;//输入类型为数字
            outAttrs.imeOptions = EditorInfo.IME_ACTION_DONE;
            return new JInputConnection(this, false);
        }
    
        class JInputConnection extends BaseInputConnection {
    
            public JInputConnection(View targetView, boolean fullEditor) {
                super(targetView, fullEditor);
            }
    
            @Override
            public boolean commitText(CharSequence text, int newCursorPosition) {
                //这里是接受输入法的文本的,我们只处理数字,所以什么操作都不做
                return super.commitText(text, newCursorPosition);
            }
    
            @Override
            public boolean sendKeyEvent(KeyEvent event) {
                if (event.getAction() == KeyEvent.ACTION_DOWN) {
                    Log.e(TAG, event.getKeyCode() + "");
                    if (event.isShiftPressed()) {//处理*#等键
                        return false;
                    }
                    int keyCode = event.getKeyCode();
                    if (keyCode >= KeyEvent.KEYCODE_0 && keyCode <= KeyEvent.KEYCODE_9) {//只处理数字
                        if (result.size() < digit) {
                            result.add(keyCode - KeyEvent.KEYCODE_0);
                            if (getChildAt(result.size() - 1) instanceof SingleNumberView) {
                                Log.e(TAG, keyCode + ";");
                                ((SingleNumberView) getChildAt(result.size() - 1)).setNumber(result.get(result.size() - 1) + "");
                            }
                            ensureFinishInput();
                        }
                        return true;
                    }
                    if (keyCode == KeyEvent.KEYCODE_DEL) {
                        if (!result.isEmpty()) {//不为空,删除最后一个
                            result.remove(result.size() - 1);
                            if (getChildAt(result.size()) instanceof SingleNumberView) {
                                ((SingleNumberView) getChildAt(result.size())).setNumber("");
                            }
                        }
                        return true;
                    }
                    if (keyCode == KeyEvent.KEYCODE_ENTER) {
                        ensureFinishInput();
                        return true;
                    }
                }
                return super.sendKeyEvent(event);
            }
    
            @Override
            public boolean deleteSurroundingText(int beforeLength, int afterLength) {
                //软键盘的删除键 DEL 无法直接监听,自己发送del事件
                if (beforeLength == 1 && afterLength == 0) {
                    return super.sendKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL))
                            && super.sendKeyEvent(new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DEL));
                }
                return super.deleteSurroundingText(beforeLength, afterLength);
            }
        }
    
        /**
         * 判断是否输入完成,输入完成后调用callback
         */
        void ensureFinishInput() {
            if (result.size() == digit) {//输入完成
                if (inputCallBack != null) {
                    StringBuffer sb = new StringBuffer();
                    for (int i : result) {
                        sb.append(i);
                    }
                    inputCallBack.onInputFinish(sb.toString());
                }
            }
        }
    

    重点是,我们要重写 onCheckIsTextEditoronCreateInputConnection。在 onCreateInputConnection 方法中,我们设置了弹出的键盘类型为数字,然后返回一个InputConnection对象。这个对象处理各种键盘输入事件。 在sendKeyEvent方法中,我们根据传入的按键事件,选择自己需要的键值,然后进行处理。

    子View管理

    当ViewGroup出现在Window上时,我们根据设置的数字位数,动态添加SingleNumberView到布局中。

        @Override
        protected void onAttachedToWindow() {
            super.onAttachedToWindow();
            if (getChildCount() <= 0) {
                for (int i = 0; i < 6; i++) {
                    SingleNumberView singleNumberView = new SingleNumberView(getContext(), null);
                    singleNumberView.setPadding(mSpacing / 2, mSpacing / 2, mSpacing / 2, mSpacing);
                    singleNumberView.setTextColor(mTextColor);
                    singleNumberView.setTextSize(mTextSize);
                    singleNumberView.setActiveColor(mActiveColor);
                    singleNumberView.setInactiveColor(mInactiveColor);
                    singleNumberView.setBottomLineWidth(mBottomLineWidth);
                    LinearLayout.LayoutParams layoutParams = new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
                    singleNumberView.setLayoutParams(layoutParams);
                    addView(singleNumberView);
                }
            }
        }
    

    键盘的管理

    一个好的View需要管理好键盘。当被点击的时候,如果键盘没有显示,要唤出键盘。当失去焦点时,要主动的关闭键盘。

        @Override
        public boolean onTouchEvent(MotionEvent event) {
            if (event.getAction() == MotionEvent.ACTION_DOWN) {//点击控件弹出输入键盘
                requestFocus();
                input.showSoftInput(this, InputMethodManager.SHOW_FORCED);
                return true;
            }
            return super.onTouchEvent(event);
        }
    
        @Override
        protected void onFocusChanged(boolean gainFocus, int direction, Rect previouslyFocusedRect) {
            super.onFocusChanged(gainFocus, direction, previouslyFocusedRect);
            if (gainFocus) {
                input.showSoftInput(this, InputMethodManager.SHOW_FORCED);
            } else {
                input.hideSoftInputFromInputMethod(this.getWindowToken(), 0);
            }
        }
    
        @Override
        public void onWindowFocusChanged(boolean hasWindowFocus) {
            super.onWindowFocusChanged(hasWindowFocus);
            if (!hasWindowFocus) {
                input.hideSoftInputFromWindow(this.getWindowToken(), 0);
            }
        }
    

    总结

    怎么样,一个自定义的View很简单吧。
    所以,一切的一切就是套路,学会了套路,切换到哪一端编程都游刃有余。
    对了,你要的全部代码
    嘿嘿,在这里不要脸的请大家给我一个Star。当然,还有你的❤

    相关文章

      网友评论

        本文标题:自定义数字输入View

        本文链接:https://www.haomeiwen.com/subject/izxseftx.html