自定义数字输入View
有一个场景,需要输入短信验证码。So,尝试着自己设计了一个这样的View。参考了一些App,发现建设银行手机银行的短信验证码界面是我想要的。所以,设计了如下图这样两个短信输入框原型。
害羞
本页图稍微有点大,可能要加载一会儿。
两种短信验证码原型图再看一个最终的效果图。
效果图
特点
随输入的字符产生动画效果(如上图)
额,当然,图有点糊了,看的不是很清楚。
分两个场景,输入和删除
输入
当用户输入一个数字的时大概有两个效果:
- 文字alpha由全透明变成不透明
- 指示底线从中间向两边发生颜色渐变
删除
当用户删除一个数字的时大概有两个效果:
- 文字alpha由不透明变成透明(消失)
- 指示底线从两边向中间发生颜色渐变
如何实现?
写代码重要的是分解。所以,看上面的原型,我们可以这样分解:一个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);
}
共创建了两个动画,分别完成我们在原型中设计的动效:
输入
当用户输入一个数字的时有两个动画效果:
- 文字alpha由全透明变成不透明
- 指示底线从中间向两边发生颜色渐变
删除
当用户删除一个数字的时有两个动画效果:
- 文字alpha由不透明变成透明(消失)
- 指示底线从两边向中间发生颜色渐变
两个动画的 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());
}
}
}
重点是,我们要重写 onCheckIsTextEditor 和 onCreateInputConnection。在 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。当然,还有你的❤