自定义数字输入View

有一个场景,需要输入短信验证码。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。当然,还有你的❤

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值