最近有点时间,准备补补自定义View,我就直接去鸿洋大神的博客了。按照博客上的文章说明,自己实现四位验证码的效果。这个过程中遇到了不少问题,也从中学到了不少,把一些知识盲点给清除了。
鸿洋博客:Android 自定义View (一)
可以参考鸿洋大神的博客一步步的来学习和进阶。
最基本的知识,想必大家都知道,自定义View有3种。
第一种,继承控件,通过继承已有控件进行扩展,实现一些自带控> 件没有的功能。
第二种,组合控件,通过多种自带控件组合在一起,形成新的控件。
第三种,绘制控件,继承自View完全靠canvas绘制出想要的图形和文字。
继承控件和组合控件,想必大家都会了,绘制控件很多人都讳莫如深,有着深深的恐惧。最初,我也是有这样的恐惧,也曾花些时间学习了一下,但是学的一知半解,由于时间关系,没有继续深入,很多知识点也没有梳理清楚。现在准备花点时间,一一揭开他们的面纱。
先说一下实现自定义View的步骤:
1、自定义View的属性
2、在构造方法中获取自定义的属性
3、重写onMeasure()
4、重写onDraw()
1、最基本的操作
自定义View最基本的操作,就是定义一个类,继承自View,实现三个构造方法。如下所示。
public class MyVerifyCode extends View {
public MyVerifyCode(Context context) {
this(context, null);
}
public MyVerifyCode(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public MyVerifyCode(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
private void init() {
}
}
三个构造方法有什么区别呢?为什么要写三个构造方法?
当我们在Java代码中使用某些控件时,会直接new出来一个对象,这时候我们一般会传入Context,此时调用的就是一个参数的构造方法。因为此时我们只是简单的想要一个对象,至于对象的属性和方法还没有相应的操作,在后面的代码中才会通过调用方法给对象的属性赋值。
当我们在xml布局文件中使用控件时,会通过一些属性来赋值,此时会调用第二个构造方法,第二个参数attrs就是接受xml文件中传入的属性值。当我们在xml布局文件中使用控件的同时,附加了style样式属性,那么就会调用第三个构造方法,也就是第三个参数defStyleAttr来接受添加的样式。所以,自定义View一般都是会实现这三种构造方法。
当然了,你说你自定义的View只需要在Java代码中使用,那么你只实现第一个构造方法也是可以的。如果不使用样式,只实现前两个构造方法也可以。
<com.sendtion.customview.view.MyVerifyCode
android:id="@+id/my_verify_code"
android:layout_width="200dp"
android:layout_height="100dp"/>
另外有一点需要注意,为了在初始化的时候统一调用,一般会在前两个构造方法中使用this调用,这样的话我们只需要在第三个构造方法中做初始化操作。
2、onMeasure测量
上面实现了自定义View最基本的操作,你说我就实现这么多,不做别的什么操作了行不行?当然可以啊,但是这样的代码有什么卵用呢?
有两个重要的方法,一个是onMeasure(widthMeasureSpec, heightMeasureSpec),一个是onDraw(Canvas canvas),这两个方法是我们主要操作的地方。
onMeasure方法不是必须的,它是用来测量View的宽高的,默认有系统自动测量。
当你自定义的View在布局中设置宽高为match_parent或者固定宽高时,得到的效果是正确的。此时,是不需要重写onMeasure方法的。
<com.sendtion.customview.view.MyVerifyCode
android:id="@+id/my_verify_code"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
然而,当你的View宽高设置为wrap_content时,得到的效果仍然是march_parent。此时,需要重写onMeasure方法,自己实现测量。
<com.sendtion.customview.view.MyVerifyCode
android:id="@+id/my_verify_code"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
自己实现测量时,要根据widthMeasureSpec和heightMeasureSpec两个参数来确定View的宽高。
widthMeasureSpec和heightMeasureSpec是一个32位的int值,高2位代表测量模式,低30位代表测量的大小,用于辅助View的测量。
重写之前先了解MeasureSpec的specMode测量模式,一共三种类型:
EXACTLY:一般是设置了明确的值或者是MATCH_PARENT
AT_MOST:表示子布局限制在一个最大值内,一般为WARP_CONTENT
UNSPECIFIED:表示子布局想要多大就多大,很少使用
下面重写onMeasure方法,经过上面的分析,我们只需要处理AT_MOST模式就可以了,当然EXACTLY也要给一个值才行。
所以我们的实现代码如下:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
int width;
int height;
if (widthMode == MeasureSpec.EXACTLY){
width = widthSize;
} else {
paint.setTextSize(vcTextSize);
paint.getTextBounds(verifyCode, 0, verifyCode.length(), rect);
float textWidth = rect.width();
Log.e("---", "onMeasure: textWidth= " + textWidth);
//width = (int) textWidth; //这样也可以,但是没计算padding
width = (int) (getPaddingLeft() + textWidth + getPaddingRight());
}
if (heightMode == MeasureSpec.EXACTLY){
height = heightSize;
} else {
paint.setTextSize(vcTextSize);
paint.getTextBounds(verifyCode, 0, verifyCode.length(), rect);
float textHeight = rect.height();
Log.e("---", "onMeasure: textHeight= " + textHeight);
//height = (int) textHeight; //这样也可以,但是没计算padding
height = (int) (getPaddingTop() + textHeight + getPaddingBottom());
}
setMeasuredDimension(width, height);
}
从以上代码可以看出,在EXACTLY模式下面,直接使用系统测量的数据,否则就自己计算。计算方式也很简单,就是字体内容的宽高加上padding数值。
View的宽度为字体的宽度rect.width()+paddingLeft+paddingRight,View的高度为字体的高度rect.height()+paddingTop+paddingBottom。
<com.sendtion.customview.view.MyVerifyCode
android:id="@+id/my_verify_code"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="16dp"/>
3、onDraw绘制
对于Android的2D绘图,我们主要借助于graphics包,其中主要包含了Canvas类、Paint类、Color类和Bitmap类等。
要绘制图形就得有画笔,根据自己的需要对画笔设置相关的属性,然后通过canvas来绘制。
画笔的常见属性如下:
setAntiAlias: 设置画笔的锯齿效果。
setColor: 设置画笔颜色 。
setARGB: 设置画笔的a,r,p,g值。
setAlpha: 设置Alpha值 。
setTextSize: 设置字体尺寸。
setStyle: 设置画笔风格,空心或者实心。
setStrokeWidth: 设置空心的边框宽度。
getColor: 得到画笔的颜色 。
getAlpha: 得到画笔的Alpha值
详细的属性解析,请参考此文:Paint的效果研究
Canvas我们可以称之为画布,能够在上面绘制各种东西,是安卓平台2D图形绘制的基础,在使用该类前需要设置好paint。
Canvas常用操作速查表:
除了进行基本的绘制外,Canvas也可以进行一些基本操作。位移、缩放、旋转、倾斜以及快照和回滚。
具体操作参考:Canvas操作
下面是具体的实现,以下操作全部在onDraw方法中。
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//画背景
paint.setColor(vcBackground);
canvas.drawRect(0, 0, getWidth(), getHeight(), paint);
}
首先,画一个矩形的纯色背景。通过画笔的setColor()方法设置矩形的颜色,然后通过画布的drawRect()方法画出矩形。
传入的参数为:drawRect(left, top, right, bottom, paint); 左上点到右下点,也就是设置View的宽高和画笔。
paint.setColor(vcBackground);
canvas.drawRect(0, 0, getWidth(), getHeight(), paint);
然后,在矩形中绘制文字。通过画笔的setColor()方法设置文字的颜色,画笔的getTextBounds()方法获取文字的宽高。通过计算公式获得文字开始绘制的起始位置,通过画布的drawText()方法绘制出文字。
文字的宽高从getTextBounds()方法的Rect参数中获取,但是这种获取方式不太精确,获取到的文字宽度会小一点,更精确的方式是paint的measureText()方法。如下所示:
float textWidth = mBound.width();//这样宽度会不全,比系统的textView短
float textWidth = mPaint.measureText(mTitleText);//比较精确的测量文本宽度的方式
传入的参数为:drawText(text, x, y, paint); 传入要绘制的文字,开始绘制的起始坐标,画笔。
//画文字
paint.setColor(vcTextColor);
paint.getTextBounds(verifyCode, 0, verifyCode.length(), rect);
int startX = getWidth()/2 - rect.width()/2;
int startY = getHeight()/2 - rect.height()/2;
canvas.drawText(verifyCode, startX, startY-offset, paint);
这样就实现了在一个矩形的红色背景上,绘制出白色的四位验证码。
然而,我们发现绘制的结果并不能令我们满意,文字并不是居中显示的,而是有点偏上的位置。这是因为文字的绘制比较特殊,它有一个基线的存在,文字的绘制以基线为基准。后面再说明文字绘制的居中显示。
先说解决方案。
Paint.FontMetricsInt fm = paint.getFontMetricsInt();
startX = (int) (getWidth() / 2 - paint.measureText(verifyCode) / 2);
startY = getHeight() / 2 + (fm.descent - fm.ascent) / 2 - fm.descent;
canvas.drawText(verifyCode, startX, startY, paint);
4、自定义属性
想要自定义属性,一般我们会在res/values目录下创建attrs.xml文件,通过declare-styleable来指定自定义属性的归属,name属性一般使用自定义View类名称,通过attr来定义需要的属性,name指定属性名,format指定属性值的类型。
<com.sendtion.customview.view.MyVerifyCode
android:id="@+id/my_verify_code"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="16dp"
app:vc_background="@color/colorAccent"
app:vc_auto_refresh="false"
app:vc_text_size="16sp"
app:vc_text_color="@color/color_f"/>
format的取值类如下所示:
boolean 表示attr取值为true或者false
color 表示attr取值是颜色类型,例如#ff3344,或者是一个指向color的资源id,例如R.color.colorAccent.
dimension 表示 attr 取值是尺寸类型,例如例如取值16sp、16dp,也可以是一个指向dimen的资源id,例如R.dimen.dp_16
float 表示attr取值是整形或者浮点型
fraction 表示 attr取值是百分数类型,只能以%结尾,例如30%
integer 表示attr取值是整型
string 表示attr取值是String类型,或者一个指向String的资源id,例如R.string.testString
reference 表示attr取值只能是一个指向资源的id。
enum 表示attr取值只能是枚举类型。
flag 表示attr取值是flag类型。
需要注意的是:refrence , 表示attr取值只能是一个指向资源的id。比如:app:vc_text_color="@color/colorAccent"
自定义属性的值需要在构造方法中取出,实现代码如下:
public MyVerifyCode(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.MyVerifyCode);
vcAutoRefresh = typedArray.getBoolean(R.styleable.MyVerifyCode_vc_auto_refresh, false);
vcTextSize = typedArray.getDimensionPixelSize(R.styleable.MyVerifyCode_vc_text_size, (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 16, getResources().getDisplayMetrics()));
vcTextColor = typedArray.getColor(R.styleable.MyVerifyCode_vc_text_color, getResources().getColor(R.color.color_f));
vcBackground =typedArray.getColor(R.styleable.MyVerifyCode_vc_background, getResources().getColor(R.color.colorAccent));
typedArray.recycle();
init();
}
需要注意的是,TypedArray使用完后需要回收。
另外,自定义属性的dp和sp值需要转换为px才行,否则以实际输入值为px值,比如默认字体大小为16sp,就需要转换为px单位,具体的转换方式如代码所示:
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 16, getResources().getDisplayMetrics()))
取大小单位时,有三个方法可以使用,这3个方法都是把dimens.xml文件中的dp或sp数值乘以屏幕scale来换算成px单位,那相乘之后可能会有小数。
三个方法的区别是:
getDimension() 返回float型px值 精确
getDimensionPixelOffset() 返回int型px值 直接把小数删除
getDimensionPixelSize() 返回int型px值 进行四舍五入
各方法使用场景:
如果你的代码中可以用float作为长度单位的话,就用getDimension()方法,因为最精确;
如果只能接收int为长度单位的的话,那就看你自己的需求来选要用getDimensionPixelOffset()或getDimensionPixelSize();
如果你在写代码的时候不记得这3个方法的区别了,无所谓了,随便用哪一个都可以,不就是相差小数点那一点点的大小而已嘛,差别很小可以忽略不计了;
5、文字居中绘制
为什么把文字居中绘制单独拿出来呢?因为文字绘制这一块有一些特殊的知识点,如果不了解,绘制的文字就不是自己想要的效果。
我们先来看一下绘制文字的方法和参数:
drawText(String text, float x, float y, Paint paint);
方法的参数很简单: text 是文字内容,x 和 y 是文字的坐标。这个坐标并不是文字的左上角,而是一个与左下角比较接近的位置。y的坐标并不是文字的底部,也不是文字的中间,而是底部靠上一点的位置。这是因为y坐标是文字基线的位置。
下面我会画出几条线,分别是矩形的中间位置十字交叉线,文字的范围线。
//画背景
paint.setColor(vcBackground);
canvas.drawRect(0, 0, getWidth(), getHeight(), paint);
//画文字
paint.setColor(vcTextColor);
paint.setStrokeWidth(2);
paint.getTextBounds(verifyCode, 0, verifyCode.length(), rect);
int startX = getWidth()/2 - rect.width()/2;
int startY = getHeight()/2 - rect.height()/2;
canvas.drawText(verifyCode, startX, startY, paint);
//画十字交叉线
paint.setColor(Color.GREEN);
paint.setStrokeWidth(2);
canvas.drawLine(0, getHeight()/2, getWidth(), getHeight()/2, paint);
canvas.drawLine(getWidth()/2, 0, getWidth()/2, getHeight(), paint);
//画范围线
canvas.drawLine(0, getHeight()/2-rect.height()/2, getWidth(), getHeight()/2-rect.height()/2, paint);
canvas.drawLine(0, getHeight()/2+rect.height()/2, getWidth(), getHeight()/2+rect.height()/2, paint);
canvas.drawLine(getWidth()/2-rect.width()/2, 0, getWidth()/2-rect.width()/2, getHeight(), paint);
canvas.drawLine(getWidth()/2+rect.width()/2, 0, getWidth()/2+rect.width()/2, getHeight(), paint);
//画起始点
paint.setColor(Color.BLUE);
paint.setStrokeWidth(10);
canvas.drawPoint(getWidth()/2-rect.width()/2, getHeight()/2-rect.height()/2, paint);
据图所示,我们看到文字的位置不是我们想要的,文字在偏上的位置,绘制的起始点也不是我们以为的位置。起始点所在的横向直线就是我们要说的基线。从其他博客借一张图。
Baseline是基线,在android中,文字的绘制都是从Baseline处开始的,Baseline往上至字符“最高处”的距离我们称之为ascent(上坡度),Baseline往下至字符“最低处”的距离我们称之为descent(下坡度);
leading(行间距)则表示上一行字符的descent到该行字符的ascent之间的距离;
top和bottom文档描述地很模糊,其实这里我们可以借鉴一下TextView对文本的绘制,TextView在绘制文本的时候总会在文本的最外层留出一些内边距,因为TextView在绘制文本的时候考虑到了类似读音符号,下图中的A上面的符号就是一个拉丁文的类似读音符号的东西:
Baseline是基线,Baseline以上是负值,以下是正值,因此 ascent,top是负值, descent和bottom是正值。
所以,原本的文字高度获取方式:
float textHeight = rect.height();
可以修改为比较精确的测量文本高度的方式:
Paint.FontMetricsInt fm = paint.getFontMetricsInt();
int textHeight = fontMetrics.bottom - fontMetrics.top;
因为top是负值,bottom是正值,所以用bottom-top获取文字的高度。那么,我们获取绘制的起始点y坐标就可以修改一下了:
int startY = getHeight() / 2 + (fm.bottom - fm.top) / 2 - fm.bottom;
当然了,也可以使用ascent和descent,有一点小小的误差,因为ascent和descent包含文字音标之类的,所以他们的值比top和bottom要大一些,计算的结果也更准确一点。
int startY = getHeight() / 2 + (fm.descent - fm.ascent) / 2 - fm.descent;
最终实现效果如下:
完整的代码如下:
/**
* 自定义验证码
*/
public class MyVerifyCode extends View {
private String verifyCode;
private Paint paint;
private Rect rect;
private boolean vcAutoRefresh;
private int vcTextSize = 56;//单位
private int vcTextColor = getResources().getColor(R.color.color_f);
private int vcBackground = getResources().getColor(R.color.colorAccent);
public MyVerifyCode(Context context) {
this(context, null);
}
public MyVerifyCode(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public MyVerifyCode(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.MyVerifyCode);
vcAutoRefresh = typedArray.getBoolean(R.styleable.MyVerifyCode_vc_auto_refresh, false);
vcTextSize = typedArray.getDimensionPixelSize(R.styleable.MyVerifyCode_vc_text_size, (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 16, getResources().getDisplayMetrics()));
vcTextColor = typedArray.getColor(R.styleable.MyVerifyCode_vc_text_color, getResources().getColor(R.color.color_f));
vcBackground =typedArray.getColor(R.styleable.MyVerifyCode_vc_background, getResources().getColor(R.color.colorAccent));
typedArray.recycle();
init();
}
private void init() {
//会出现三位数的情况
verifyCode = String.valueOf((int) (Math.random() * 10000));//四位验证码
verifyCode = "abcdefg";
paint = new Paint();
//paint.setTextAlign(Paint.Align.CENTER);
paint.setTextSize(vcTextSize);
//Paint.ANTI_ALIAS_FLAG :抗锯齿标志
//Paint.FILTER_BITMAP_FLAG : 使位图过滤的位掩码标志
//Paint.DITHER_FLAG : 使位图进行有利的抖动的位掩码标志
//Paint.UNDERLINE_TEXT_FLAG : 下划线
//Paint.STRIKE_THRU_TEXT_FLAG : 中划线
//Paint.FAKE_BOLD_TEXT_FLAG : 加粗
//Paint.LINEAR_TEXT_FLAG : 使文本平滑线性扩展的油漆标志
//Paint.SUBPIXEL_TEXT_FLAG : 使文本的亚像素定位的绘图标志
//Paint.EMBEDDED_BITMAP_TEXT_FLAG : 绘制文本时允许使用位图字体的绘图标志
paint.setFlags(Paint.ANTI_ALIAS_FLAG);
rect = new Rect();
if (vcAutoRefresh) {
}
setOnClickListener(new OnClickListener() {
@Override
public void onClick(View view) {
verifyCode = String.valueOf((int) (Math.random() * 10000));//四位验证码
postInvalidate();
}
});
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
int width;
int height;
if (widthMode == MeasureSpec.EXACTLY){
width = widthSize;
} else {
paint.setTextSize(vcTextSize);
paint.getTextBounds(verifyCode, 0, verifyCode.length(), rect);
float textWidth = rect.width();
Log.e("---", "onMeasure: textWidth= " + textWidth);
//width = (int) textWidth; //这样也可以,但是没计算padding
width = (int) (getPaddingLeft() + textWidth + getPaddingRight());
}
if (heightMode == MeasureSpec.EXACTLY){
height = heightSize;
} else {
paint.setTextSize(vcTextSize);
paint.getTextBounds(verifyCode, 0, verifyCode.length(), rect);
float textHeight = rect.height();
Log.e("---", "onMeasure: textHeight= " + textHeight);
//height = (int) textHeight; //这样也可以,但是没计算padding
height = (int) (getPaddingTop() + textHeight + getPaddingBottom());
}
setMeasuredDimension(width, height);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//画背景
paint.setColor(vcBackground);
canvas.drawRect(0, 0, getWidth(), getHeight(), paint);
//画文字
Log.e("---", "onDraw: getWidth=" + getWidth() + ", getHeight=" + getHeight());
paint.setColor(vcTextColor);
paint.setStrokeWidth(2);
paint.getTextBounds(verifyCode, 0, verifyCode.length(), rect);
//第一种方式,计算的基准点偏上
int startX = getWidth()/2 - rect.width()/2;
int startY = getHeight()/2 - rect.height()/2;
//canvas.drawText(verifyCode, startX, startY, paint);
//第二种方式,貌似更准确,计算的基准点更准,内容变化也会居中显示
Paint.FontMetricsInt fm = paint.getFontMetricsInt();
startX = (int) (getWidth() / 2 - paint.measureText(verifyCode) / 2);
startY = getHeight() / 2 + (fm.bottom - fm.top) / 2 - fm.bottom;
//startY = getHeight() / 2 + (fm.descent - fm.ascent) / 2 - fm.descent;
canvas.drawText(verifyCode, startX, startY, paint);
//画十字交叉线
paint.setColor(Color.GREEN);
paint.setStrokeWidth(2);
canvas.drawLine(0, getHeight()/2, getWidth(), getHeight()/2, paint);
canvas.drawLine(getWidth()/2, 0, getWidth()/2, getHeight(), paint);
//画基准线
canvas.drawLine(0, getHeight()/2-rect.height()/2, getWidth(), getHeight()/2-rect.height()/2, paint);
canvas.drawLine(0, getHeight()/2+rect.height()/2, getWidth(), getHeight()/2+rect.height()/2, paint);
canvas.drawLine(getWidth()/2-rect.width()/2, 0, getWidth()/2-rect.width()/2, getHeight(), paint);
canvas.drawLine(getWidth()/2+rect.width()/2, 0, getWidth()/2+rect.width()/2, getHeight(), paint);
//画基准点
paint.setColor(Color.BLUE);
paint.setStrokeWidth(10);
canvas.drawPoint(startX, startY, paint);
}
}
全部源码:github地址
** 参考文章:**
- https://www.jianshu.com/p/8c10a8a8e669
- https://blog.csdn.net/SilenceOO/article/details/73498331
- https://www.jianshu.com/p/1728b725b4a6
本文完结。