废话不多说了,先给大家展示下自定义view效果图,如果大家觉得还不错的话,请继续往下阅读。
怎么样,这种验证码是不是很常见呢,下面我们就自己动手实现这种效果,自己动手,丰衣足食,哈哈~
一、 自定义view的步骤
自定义view一直被认为android进阶通向高手的必经之路,其实自定义view好简单,自定义view真正难的是如何绘制出高难度的图形,这需要有好的数学功底(后悔没有好好学数学了~),因为绘制图形经常要计算坐标点及类似的几何变换等等。自定义view通常只需要以下几个步骤:
写一个类继承View类;
重新View的构造方法;
测量View的大小,也就是重写onMeasure()方法;
重新onDraw()方法。
其中第三步不是必须的,只有当系统无法确定自定义的view的大小的时候需要我们自己重写onMeasure()方法来完成自定义view大小的测量,因为如果用户(程序员)在使用我们的自定义view的时候没有指定其精确大小(宽度或高度),如:布局文件中layout_width或layout_heigth属性值为wrap_content而不是match_parent或某个精确的值,那么系统就不知道我们自定义view在onDraw()中绘制的图形的大小,所以通常要让我们自定义view支持wrap_content那么我们就必须重写onMeasure方法来告诉系统我们要绘制的view的大小(宽度和高度)。
还有,如果我们自定义view需要一些特殊的属性,那么我们还需要自定义属性,这篇文章将会涉及到自定义属性和上面的四个步骤的内容。
二、 自定义view的实现
要实现这种验证码控件,我们需要先分析一下它要怎么实现。通过看上面的效果图,我们可以知道要实现这种效果,首先需要在绘制验证码字符串,即图中的文本部分,然后绘制一些干扰点,再就是绘制干扰线了,分析完毕。下面我们根据分析结果一步步实现这种效果。
1. 继承View,重写构造方法
写一个类继承View,然后重新它的构造方法
/** * Created by lt on 2016/3/2. */ public class ValidationCode extends View{ /** * 在java代码中创建view的时候调用,即new * @param context */ public ValidationCode(Context context) { this(context,null); } /** * 在xml布局文件中使用view但没有指定style的时候调用 * @param context * @param attrs */ public ValidationCode(Context context, AttributeSet attrs) { this(context, attrs, 0); } /** * 在xml布局文件中使用view并指定style的时候调用 * @param context * @param attrs * @param defStyleAttr */ public ValidationCode(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); // 做一些初始化工作 init(); } }
View有三个构造方法,一般的做法都是让一个参数和两个参数的构造方法调用三个构造参数的方法,这三个构造方法的调用情况看方法上面的注释。在这个构造方法里面我们先做一些初始化随机验证码字符串,画笔等工作:
/** * 初始化一些数据 */ private void init() { // 生成随机数字和字母组合 mCodeString = getCharAndNumr(mCodeCount); // 初始化文字画笔 mTextPaint = new Paint(); mTextPaint.setStrokeWidth(3); // 画笔大小为3 mTextPaint.setTextSize(mTextSize); // 设置文字大小 // 初始化干扰点画笔 mPointPaint = new Paint(); mPointPaint.setStrokeWidth(6); mPointPaint.setStrokeCap(Paint.Cap.ROUND); // 设置断点处为圆形 // 初始化干扰线画笔 mPathPaint = new Paint(); mPathPaint.setStrokeWidth(5); mPathPaint.setColor(Color.GRAY); mPathPaint.setStyle(Paint.Style.STROKE); // 设置画笔为空心 mPathPaint.setStrokeCap(Paint.Cap.ROUND); // 设置断点处为圆形 // 取得验证码字符串显示的宽度值 mTextWidth = mTextPaint.measureText(mCodeString); }
到这里,我们就完成了自定义View步骤中的前面的两小步了,接下来就是完成第三步,即重写onMeasure()进行我们自定义view大小(宽高)的测量了:
2. 重写onMeasure(),完成View大小的测量
/** * 要像layout_width和layout_height属性支持wrap_content就必须重新这个方法 * @param widthMeasureSpec * @param heightMeasureSpec */ @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { // 分别测量控件的宽度和高度,基本为模板方法 int measureWidth = measureWidth(widthMeasureSpec); int measureHeight = measureHeight(heightMeasureSpec); // 其实这个方法最终会调用setMeasuredDimension(int measureWidth,int measureHeight); // 将测量出来的宽高设置进去完成测量 setMeasuredDimension(measureWidth, measureHeight); }
测量宽度的方法:
/** * 测量宽度 * @param widthMeasureSpec */ private int measureWidth(int widthMeasureSpec) { int result = (int) (mTextWidth*1.8f); int widthMode = MeasureSpec.getMode(widthMeasureSpec); int widthSize = MeasureSpec.getSize(widthMeasureSpec); if(widthMode == MeasureSpec.EXACTLY){ // 精确测量模式,即布局文件中layout_width或layout_height一般为精确的值或match_parent result = widthSize; // 既然是精确模式,那么直接返回测量的宽度即可 }else{ if(widthMode == MeasureSpec.AT_MOST) { // 最大值模式,即布局文件中layout_width或layout_height一般为wrap_content result = Math.min(result,widthSize); } } return result; }
测量高度的方法:
/** * 测量高度 * @param heightMeasureSpec */ private int measureHeight(int heightMeasureSpec) { int result = (int) (mTextWidth/1.6f); int heightMode = MeasureSpec.getMode(heightMeasureSpec); int heightSize = MeasureSpec.getSize(heightMeasureSpec); if(heightMode == MeasureSpec.EXACTLY){ // 精确测量模式,即布局文件中layout_width或layout_height一般为精确的值或match_parent result = heightSize; // 既然是精确模式,那么直接返回测量的宽度即可 }else{ if(heightMode == MeasureSpec.AT_MOST) { // 最大值模式,即布局文件中layout_width或layout_height一般为wrap_content result = Math.min(result,heightSize); } } return result; }
说明:其实onMeasure()方法最终会调用setMeasuredDimension(int measureWidth,int measureHeight);将测量出来的宽高设置进去完成测量,而我们要做的就是测量得到宽度和高度的值,测量宽度和高度的方法最重要的就是得到当用户(程序员)没有给我们的控件指定精确的值(具体数值或match_parent)时合适的宽度和高度,所以,以上测量宽度和高度的方法基本上是一个模板方法,要做的就是得到result的一个合适的值,这里我们无需关注给result的那个值,因为这个值根据控件算出来的一个合适的值(也许不是很合适)。
完成了控件的测量,那么接下来我们还要完成控件的绘制这一大步,也就是自定义view的核心的一步重写onDraw()方法绘制图形。
3. 重写onDraw(),绘制图形
根据我们上面的分析,我们需要绘制验证码文本字符串,干扰点,干扰线。由于干扰点和干扰线需要坐标和路径来绘制, 所以在绘制之前先做一些初始化随机干扰点坐标和干扰线路径:
private void initData() { // 获取控件的宽和高,此时已经测量完成 mHeight = getHeight(); mWidth = getWidth(); mPoints.clear(); // 生成干扰点坐标 for(int i=0;i<150;i++){ PointF pointF = new PointF(mRandom.nextInt(mWidth)+10,mRandom.nextInt(mHeight)+10); mPoints.add(pointF); } mPaths.clear(); // 生成干扰线坐标 for(int i=0;i<2;i++){ Path path = new Path(); int startX = mRandom.nextInt(mWidth/3)+10; int startY = mRandom.nextInt(mHeight/3)+10; int endX = mRandom.nextInt(mWidth/2)+mWidth/2-10; int endY = mRandom.nextInt(mHeight/2)+mHeight/2-10; path.moveTo(startX,startY); path.quadTo(Math.abs(endX-startX)/2,Math.abs(endY-startY)/2,endX,endY); mPaths.add(path); } }
有了这些数据之后,我们可以开始绘制图形了。
(1)绘制验证码文本字符串
由于验证码文本字符串是随机生成的,所以我们需要利用代码来随机生成这种随机验证码:
/** * java生成随机数字和字母组合 * @param length[生成随机数的长度] * @return */ public static String getCharAndNumr(int length) { String val = ""; Random random = new Random(); for (int i = 0; i < length; i++) { // 输出字母还是数字 String charOrNum = random.nextInt(2) % 2 == 0 ? "char" : "num"; // 字符串 if ("char".equalsIgnoreCase(charOrNum)) { // 取得大写字母还是小写字母 int choice = random.nextInt(2) % 2 == 0 ? 65 : 97; val += (char) (choice + random.nextInt(26)); } else if ("num".equalsIgnoreCase(charOrNum)) { // 数字 val += String.valueOf(random.nextInt(10)); } } return val; }
这种代码是java基础,相信大家都看得懂,看不懂也没关系,这种代码网上随便一搜就有,其实我也是直接从网上搜的,嘿嘿~。
android的2D图形api canvas提供了drawXXX()方法来完成各种图形的绘制,其中就有drawText()方法来绘制文本,同时还有drawPosText()在给定的坐标点上绘制文本,drawTextOnPath()在给定途径上绘制图形。仔细观察上面的效果图,发现文本有的不是水平的,即有的被倾斜了,这就可以给我们的验证码提升一定的识别难度,要实现文字倾斜效果,我们可以通过drawTextOnPath()在给定路径绘制文本达到倾斜效果,然而这种方法实现比较困难(坐标点和路径难以计算),所以,我们可以通过canvas提供的位置变换方法rorate()结合drawText()实现文本倾斜效果。
int length = mCodeString.length(); float charLength = mTextWidth/length; for(int i=1;i<=length;i++){ int offsetDegree = mRandom.nextInt(15); // 这里只会产生0和1,如果是1那么正旋转正角度,否则旋转负角度 offsetDegree = mRandom.nextInt(2) == 1?offsetDegree:-offsetDegree; canvas.save(); canvas.rotate(offsetDegree, mWidth / 2, mHeight / 2); // 给画笔设置随机颜色,+20是为了去除一些边界值 mTextPaint.setARGB(255, mRandom.nextInt(200) + 20, mRandom.nextInt(200) + 20, mRandom.nextInt(200) + 20);canvas.drawText(String.valueOf(mCodeString.charAt(i - 1)), (i-1) * charLength * 1.6f+30, mHeight * 2 / 3f, mTextPaint); canvas.restore(); }
这段代码通过for循环分别绘制验证码字符串中的每个字符,每绘制一个字符都将画布旋转一个随机的正负角度,然后通过drawText()方法绘制字符,每个字符的绘制起点坐标根据字符的长度和位置不同而不同,这个自己计算,这里也许也不是很合适。要注意的是,每次对画布canvas进行位置变换的时候都要先调用canvas.save()方法保存好之前绘制的图形,绘制结束后调用canvas.restore()恢复画布的位置,以便下次绘制图形的时候不会由于之前画布的位置变化而受影响。
(2)绘制干扰点
// 产生干扰效果1 -- 干扰点 for(PointF pointF : mPoints){ mPointPaint.setARGB(255,mRandom.nextInt(200)+20,mRandom.nextInt(200)+20,mRandom.nextInt(200)+20); canvas.drawPoint(pointF.x,pointF.y,mPointPaint); }
给干扰点画笔设置随机颜色,然后根据随机产生的点的坐标利用canvas.drawPoint()绘制点。
(3)绘制干扰线
// 产生干扰效果2 -- 干扰线 for(Path path : mPaths){ mPathPaint.setARGB(255, mRandom.nextInt(200) + 20, mRandom.nextInt(200) + 20, mRandom.nextInt(200) + 20); canvas.drawPath(path, mPathPaint); }
给干扰线画笔设置随机颜色,然后根据随机产生路径利用canvas.drawPath()绘制贝塞尔曲线,从而绘制出干扰线。
4. 重写onTouchEvent,定制View事件
这里做这一步是为了实现当我们点击我们的自定义View的时候,完成一些操作,即定制View事件。这里,我们需要当用户点击验证码控件的时候,改变验证码的文本字符串。
@Override public boolean onTouchEvent(MotionEvent event) { switch (event.getAction()){ case MotionEvent.ACTION_DOWN: // 重新生成随机数字和字母组合 mCodeString = getCharAndNumr(mCodeCount); invalidate(); break; default: break; } return super.onTouchEvent(event); }
OK,到这里我们的这个自定义View就基本完成了,可能大家会问,这个自定义View是不是扩展性太差了,定制性太低了,说好的自定义属性呢?跑哪里去了。不要急,下面我们就来自定义我们自己View的属性,自定义属性。
5. 自定义属性,提高自定义View的可定制性
(1)在资源文件attrs.xml文件中定义我们的属性(集)
<?xml version="1.0" encoding="utf-8"?> <resources> <declare-styleable name="IndentifyingCode"> <attr name="codeCount" format="integer|reference"></attr> <attr name="textSize" format="dimension"></attr> </declare-styleable> </resources>
说明:
在attrs.xml文件中的attr节点中定义我们的属性,定义属性需要name属性表示我们的属性值,同时需要format属性表示属性值的格式,其格式有很多种,如果属性值可以使多种格式,那么格式间用”|”分开;
declare-styleable节点用来定义我们自定义属性集,其name属性指定了该属性集的名称,可以任意,但一般为自定义控件的名称;
如果属性已经定义了(如layout_width),那么可以直接引用该属性,不要指定格式了。
(2)在布局文件中引用自定义属性,注意需要引入命名空间
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" xmlns:lt="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent"> <com.lt.identifyingcode.ValidationCode android:id="@+id/validationCode" android:layout_width="wrap_content" android:layout_centerInParent="true" lt:textSize="25sp" android:background="@android:color/darker_gray" android:layout_height="wrap_content"/> </RelativeLayout>
引入命名空间在现在只需要添加xmlns:lt="http://schemas.android.com/apk/res-auto"即可(lt换成你自己的命名空间名称),而在以前引入命名空间方式为xmlns:custom="http://schemas.android.com/apk/res/com.example.customview01",res后面的包路径指的是项目的package`
(3)在构造方法中获取自定义属性的值
TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.IndentifyingCode); mCodeCount = typedArray.getInteger(R.styleable.IndentifyingCode_codeCount, 5); // 获取布局中验证码位数属性值,默认为5个 // 获取布局中验证码文字的大小,默认为20sp mTextSize = typedArray.getDimension(R.styleable.IndentifyingCode_textSize, typedArray.getDimensionPixelSize(R.styleable.IndentifyingCode_textSize, (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 20, getResources().getDisplayMetrics()))); // 一个好的习惯是用完资源要记得回收,就想打开数据库和IO流用完后要记得关闭一样 typedArray.recycle();
OK,自定义属性也完成了,值也获取到了,那么我们只需要将定制的属性值在我们onDraw()绘制的时候使用到就行了,自定义属性就是这么简单~,看到这里,也许有点混乱了,看一下完整代码整理一下。
package com.lt.identifyingcode; import android.content.Context; import android.content.res.TypedArray; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; import android.graphics.Path; import android.graphics.PointF; import android.util.AttributeSet; import android.util.TypedValue; import android.view.MotionEvent; import android.view.View; import java.util.ArrayList; import java.util.Random; /** * Created by lt on 2016/3/2. */ public class ValidationCode extends View{ /** * 控件的宽度 */ private int mWidth; /** * 控件的高度 */ private int mHeight; /** * 验证码文本画笔 */ private Paint mTextPaint; // 文本画笔 /** * 干扰点坐标的集合 */ private ArrayList<PointF> mPoints = new ArrayList<PointF>(); private Random mRandom = new Random();; /** * 干扰点画笔 */ private Paint mPointPaint; /** * 绘制贝塞尔曲线的路径集合 */ private ArrayList<Path> mPaths = new ArrayList<Path>(); /** * 干扰线画笔 */ private Paint mPathPaint; /** * 验证码字符串 */ private String mCodeString; /** * 验证码的位数 */ private int mCodeCount; /** * 验证码字符的大小 */ private float mTextSize; /** * 验证码字符串的显示宽度 */ private float mTextWidth; /** * 在java代码中创建view的时候调用,即new * @param context */ public ValidationCode(Context context) { this(context,null); } /** * 在xml布局文件中使用view但没有指定style的时候调用 * @param context * @param attrs */ public ValidationCode(Context context, AttributeSet attrs) { this(context, attrs, 0); } /** * 在xml布局文件中使用view并指定style的时候调用 * @param context * @param attrs * @param defStyleAttr */ public ValidationCode(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); getAttrValues(context, attrs); // 做一些初始化工作 init(); } /** * 获取布局文件中的值 * @param context */ private void getAttrValues(Context context,AttributeSet attrs) { TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.IndentifyingCode); mCodeCount = typedArray.getInteger(R.styleable.IndentifyingCode_codeCount, 5); // 获取布局中验证码位数属性值,默认为5个 // 获取布局中验证码文字的大小,默认为20sp mTextSize = typedArray.getDimension(R.styleable.IndentifyingCode_textSize, typedArray.getDimensionPixelSize(R.styleable.IndentifyingCode_textSize, (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 20, getResources().getDisplayMetrics()))); // 一个好的习惯是用完资源要记得回收,就想打开数据库和IO流用完后要记得关闭一样 typedArray.recycle(); } /** * 要像layout_width和layout_height属性支持wrap_content就必须重新这个方法 * @param widthMeasureSpec * @param heightMeasureSpec */ @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { // 分别测量控件的宽度和高度,基本为模板方法 int measureWidth = measureWidth(widthMeasureSpec); int measureHeight = measureHeight(heightMeasureSpec); // 其实这个方法最终会调用setMeasuredDimension(int measureWidth,int measureHeight); // 将测量出来的宽高设置进去完成测量 setMeasuredDimension(measureWidth, measureHeight); } @Override protected void onDraw(Canvas canvas) { // 初始化数据 initData(); int length = mCodeString.length(); float charLength = mTextWidth/length; for(int i=1;i<=length;i++){ int offsetDegree = mRandom.nextInt(15); // 这里只会产生0和1,如果是1那么正旋转正角度,否则旋转负角度 offsetDegree = mRandom.nextInt(2) == 1?offsetDegree:-offsetDegree; canvas.save(); canvas.rotate(offsetDegree, mWidth / 2, mHeight / 2); // 给画笔设置随机颜色 mTextPaint.setARGB(255, mRandom.nextInt(200) + 20, mRandom.nextInt(200) + 20, mRandom.nextInt(200) + 20); canvas.drawText(String.valueOf(mCodeString.charAt(i - 1)), (i-1) * charLength * 1.6f+30, mHeight * 2 / 3f, mTextPaint); canvas.restore(); } // 产生干扰效果1 -- 干扰点 for(PointF pointF : mPoints){ mPointPaint.setARGB(255,mRandom.nextInt(200)+20,mRandom.nextInt(200)+20,mRandom.nextInt(200)+20); canvas.drawPoint(pointF.x,pointF.y,mPointPaint); } // 产生干扰效果2 -- 干扰线 for(Path path : mPaths){ mPathPaint.setARGB(255, mRandom.nextInt(200) + 20, mRandom.nextInt(200) + 20, mRandom.nextInt(200) + 20); canvas.drawPath(path, mPathPaint); } } private void initData() { // 获取控件的宽和高,此时已经测量完成 mHeight = getHeight(); mWidth = getWidth(); mPoints.clear(); // 生成干扰点坐标 for(int i=0;i<150;i++){ PointF pointF = new PointF(mRandom.nextInt(mWidth)+10,mRandom.nextInt(mHeight)+10); mPoints.add(pointF); } mPaths.clear(); // 生成干扰线坐标 for(int i=0;i<2;i++){ Path path = new Path(); int startX = mRandom.nextInt(mWidth/3)+10; int startY = mRandom.nextInt(mHeight/3)+10; int endX = mRandom.nextInt(mWidth/2)+mWidth/2-10; int endY = mRandom.nextInt(mHeight/2)+mHeight/2-10; path.moveTo(startX,startY); path.quadTo(Math.abs(endX-startX)/2,Math.abs(endY-startY)/2,endX,endY); mPaths.add(path); } } /** * 初始化一些数据 */ private void init() { // 生成随机数字和字母组合 mCodeString = getCharAndNumr(mCodeCount); // 初始化文字画笔 mTextPaint = new Paint(); mTextPaint.setStrokeWidth(3); // 画笔大小为3 mTextPaint.setTextSize(mTextSize); // 设置文字大小 // 初始化干扰点画笔 mPointPaint = new Paint(); mPointPaint.setStrokeWidth(6); mPointPaint.setStrokeCap(Paint.Cap.ROUND); // 设置断点处为圆形 // 初始化干扰线画笔 mPathPaint = new Paint(); mPathPaint.setStrokeWidth(5); mPathPaint.setColor(Color.GRAY); mPathPaint.setStyle(Paint.Style.STROKE); // 设置画笔为空心 mPathPaint.setStrokeCap(Paint.Cap.ROUND); // 设置断点处为圆形 // 取得验证码字符串显示的宽度值 mTextWidth = mTextPaint.measureText(mCodeString); } /** * java生成随机数字和字母组合 * @param length[生成随机数的长度] * @return */ public static String getCharAndNumr(int length) { String val = ""; Random random = new Random(); for (int i = 0; i < length; i++) { // 输出字母还是数字 String charOrNum = random.nextInt(2) % 2 == 0 ? "char" : "num"; // 字符串 if ("char".equalsIgnoreCase(charOrNum)) { // 取得大写字母还是小写字母 int choice = random.nextInt(2) % 2 == 0 ? 65 : 97; val += (char) (choice + random.nextInt(26)); } else if ("num".equalsIgnoreCase(charOrNum)) { // 数字 val += String.valueOf(random.nextInt(10)); } } return val; } @Override public boolean onTouchEvent(MotionEvent event) { switch (event.getAction()){ case MotionEvent.ACTION_DOWN: // 重新生成随机数字和字母组合 mCodeString = getCharAndNumr(mCodeCount); invalidate(); break; default: break; } return super.onTouchEvent(event); } /** * 测量宽度 * @param widthMeasureSpec */ private int measureWidth(int widthMeasureSpec) { int result = (int) (mTextWidth*1.8f); int widthMode = MeasureSpec.getMode(widthMeasureSpec); int widthSize = MeasureSpec.getSize(widthMeasureSpec); if(widthMode == MeasureSpec.EXACTLY){ // 精确测量模式,即布局文件中layout_width或layout_height一般为精确的值或match_parent result = widthSize; // 既然是精确模式,那么直接返回测量的宽度即可 }else{ if(widthMode == MeasureSpec.AT_MOST) { // 最大值模式,即布局文件中layout_width或layout_height一般为wrap_content result = Math.min(result,widthSize); } } return result; } /** * 测量高度 * @param heightMeasureSpec */ private int measureHeight(int heightMeasureSpec) { int result = (int) (mTextWidth/1.6f); int heightMode = MeasureSpec.getMode(heightMeasureSpec); int heightSize = MeasureSpec.getSize(heightMeasureSpec); if(heightMode == MeasureSpec.EXACTLY){ // 精确测量模式,即布局文件中layout_width或layout_height一般为精确的值或match_parent result = heightSize; // 既然是精确模式,那么直接返回测量的宽度即可 }else{ if(heightMode == MeasureSpec.AT_MOST) { // 最大值模式,即布局文件中layout_width或layout_height一般为wrap_content result = Math.min(result,heightSize); } } return result; } /** * 获取验证码字符串,进行匹配的时候只需要字符串比较即可(具体比较规则自己决定) * @return 验证码字符串 */ public String getCodeString() { return mCodeString; } }
总结:这里与其说自定义View到不如说是绘制图形,关键在于坐标点的计算,这里在计算坐标上也许不太好,以上是给大家分享Android自定义view制作绚丽的验证码,希望对大家有所帮助!大家有什么好的思路或者建议希望可以留言告诉我,感激不尽~。