Android自定义点选验证码

上一篇文章里,通过简单的思路分析和代码演示,最终实现了自定义拼图验证码
的效果。接下来,我们要实现的是如下图所示的汉字点选验证码:

ezgif-3-a89a55a08fab.gif

分析

效果图可以分成上下两个部分,其中第 2 可以通过常规组合布局的方式实现,我们只需重点关注第 1 即可,实现了第 1 ,然后通过组合布局的方式,把上下两个部分放一起,验证码就算是大功告成了。


QQ20190705-093535.png

第 1 部分思路分析:

  1. 准备一张图片,通过canvas.drawBitmap()方法画出背景图
  2. 随机生成4个坐标点,通过canvas.drawText()方法依次把预设的汉字写到画板上。这里的随机,你要考虑以下情况
    • 边界。如果生成的左边刚好在(x,fontSize)怎么办,这时候文字可能跑到画布边界外面去了。
      绘制越界.png
  • 重合。如果第一次生成的坐标是(100,100), 第二次生成的坐标也是(100,100),或者(101,101),那两个汉字岂不是绘制在同一位置了。
    绘制正常.png

    绘制重合.png

    所以,除了要结合画布大小和文字大小,计算出一块安全区域,在安全区域内随机生成坐标点,还要保存已绘制文字区域范围 region,对下一次要随机生成的坐标点,先判断是不是在已绘制文字区域内,避免文字在同一块区域重复绘制。
  1. 在用户交互上,我们希望点击点如果在文字区域内,则显示用户点击的顺序,如果不在,则不处理点击事件。这里可以点击点为中心画圆背景,然后结合圆背景大小、序号文字大小,计算序号文字需要显示的位置,尽量显示在圆正中。
  2. 完成界面的绘制后,剩下的就是逻辑判断和回调接口处理,根据记录的文字和点击顺序判断就可以了。对外暴露设置文字、点击刷新和判断结果回调。

代码实现

TapVerificationView.java

package com.example.qingfengwei.myapplication;

import android.content.Context;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Matrix;
import android.graphics.Paint;
import android.graphics.Point;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffXfermode;
import android.graphics.Rect;
import android.graphics.RectF;
import android.graphics.Region;
import android.os.Handler;
import android.os.Message;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.widget.Toast;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;

public class TapVerificationView extends View {

    /*画布宽高*/
    private int width;
    private int height;

    private Bitmap oldBitmap;
    /*根据准备的图片重新调整尺寸后的背景图*/
    private Bitmap bgBitmap;
    private Paint bgPaint;
    private RectF bgRectF;

    /*验证码文字画笔*/
    private Paint textPaint;
    private Paint selectPaint;
    private Paint selectTextPaint;
    private List<Region> regions = new ArrayList<Region>();

    private Random random;

    private String fonts = "";
    private int checkCode = 0;


    private List<Point> tapPoints = new ArrayList<Point>();
    private List<Integer> tapIndex = new ArrayList<Integer>();

    private List<Point> textPoints = new ArrayList<Point>();
    private List<Integer> degrees = new ArrayList<Integer>();

    private boolean isInit = true;

    public TapVerificationView(Context context) {
        this(context, null);
    }

    public TapVerificationView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public TapVerificationView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        oldBitmap = BitmapFactory.decodeResource(getResources(), R.mipmap.syzt);

        bgPaint = new Paint();
        bgPaint.setAntiAlias(true);
        bgPaint.setFilterBitmap(true);

        textPaint = new Paint();
        textPaint.setAntiAlias(true);
        textPaint.setFakeBoldText(true);
        textPaint.setColor(Color.parseColor("#AA000000"));
        textPaint.setShadowLayer(3, 2, 2, Color.RED);
        textPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.LIGHTEN));

        selectPaint = new Paint();
        selectPaint.setAntiAlias(true);
        selectPaint.setStyle(Paint.Style.FILL);
        selectPaint.setColor(Color.WHITE);


        selectTextPaint = new Paint();

        random = new Random();

        int temp = fonts.length() - 1;
        while (temp > -1) {
            checkCode += temp * Math.pow(10, temp);
            temp--;
        }
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);

        int minimumWidth = getSuggestedMinimumWidth();
        int minimumHeight = getSuggestedMinimumHeight();
        width = measureSize(minimumWidth, widthMeasureSpec);
        height = width;
        bgBitmap = clipBitmap(oldBitmap, width, height);
        bgRectF = new RectF(0, 0, width, height);
        textPaint.setTextSize(width / 6);
        setMeasuredDimension(width, height);
    }

    public Bitmap clipBitmap(Bitmap bm, int newWidth, int newHeight) {
        int width = bm.getWidth();
        int height = bm.getHeight();
        float scaleWidth = ((float) newWidth) / width;
        float scaleHeight = ((float) newHeight) / height;
        Matrix matrix = new Matrix();
        matrix.postScale(scaleWidth, scaleHeight);
        return Bitmap.createBitmap(bm, 0, 0, width, height, matrix, true);
    }

    private int measureSize(int defaultSize, int measureSpec) {
        int mode = MeasureSpec.getMode(measureSpec);
        int size = MeasureSpec.getSize(measureSpec);
        int result = defaultSize;
        switch (mode) {
            case MeasureSpec.UNSPECIFIED:
                result = defaultSize;
                break;
            case MeasureSpec.AT_MOST:
            case MeasureSpec.EXACTLY:
                result = size;
                break;
        }
        return result;
    }

    public static int dp2px(float dp) {
        float density = Resources.getSystem().getDisplayMetrics().density;
        return (int) (density * dp + 0.5f);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                int x = (int) event.getX();
                int y = (int) event.getY();
                for (Region region : regions) {
                    if (region.contains(x, y)) {

                        isInit = false;

                        int index = regions.indexOf(region);
                        if (!tapIndex.contains(index)) {
                            tapIndex.add(index);
                            tapPoints.add(new Point(x, y));
                        }


                        if (tapIndex.size() == fonts.length()) {
                            StringBuilder s = new StringBuilder();
                            for (Integer i : tapIndex) {
                                s.append(i);
                            }
                            int result = Integer.parseInt(s.toString());
                            if (result == checkCode) {
                                handler.sendEmptyMessage(1);
                            } else {
                                handler.sendEmptyMessage(0);
                            }
                        }

                        invalidate();
                    }
                }
        }
        return false;
    }

    private Handler handler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            super.handleMessage(msg);
            int result = msg.what;
            switch (result) {
                case 1:
                    Toast.makeText(getContext(), "验证成功!", Toast.LENGTH_SHORT).show();
                    listener.onResult(true);
                    break;
                default:
                    Toast.makeText(getContext(), "验证失败!", Toast.LENGTH_SHORT).show();
                    postDelayed(new Runnable() {
                        @Override
                        public void run() {
                            reDrew();
                            listener.onResult(false);
                        }
                    }, 1000);
                    break;
            }
        }
    };

    private boolean checkCover(int x, int y) {
        for (Region region : regions) {
            if (region.contains(x, y)) {
                return true;
            }
        }
        return false;
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        regions.clear();
        canvas.drawBitmap(bgBitmap, null, bgRectF, bgPaint);

        /*在处理点击的时候需要绘制用户点击的顺序,这时候要判断是初始化验证码,还是用户在点击需要绘制点击的序号
        * 如果是初始化验证码,就随机生成文字,绘制文字
        * 如果是用户在点击,需要绘制点击的顺序,这时候就不能重新随机生成坐标点,要让文字位置保持不动才行,否则会出现点击一次,随机生成一次验证码的情况
        * */
        if (isInit) {

            textPoints.clear();
            degrees.clear();
            tapIndex.clear();
            tapPoints.clear();

            for (int i = 0; i < fonts.length(); i++) {
                /*这里把文字倒着写是为了后面的验证方便*/
                String s = String.valueOf(fonts.charAt(fonts.length() - i - 1));
                int textSize = (int) textPaint.measureText(s);
                canvas.save();
                /*在指定范围随机生成坐标点*/
                int x = random.nextInt(width - textSize);
                int y = random.nextInt(height - textSize);

                /*如果检测到点和文字区域有重合,则要重新随机生成点坐标,这里四个条件,分别是如果以(x,y)为文字绘制坐标  的  四个角的位置
                * 这里有一点绕,理解困难的最好在草纸上比划比划*/
                while (checkCover(x, y) || checkCover(x, y + textSize) || checkCover(x + textSize, y) || checkCover(x + textSize, y + textSize)) {
                    x = random.nextInt(width - textSize);
                    y = random.nextInt(height - textSize);
                }

                textPoints.add(new Point(x, y));
                canvas.translate(x, y);
                /*随机生成一个30以内的整数,使文字倾斜一定的角度*/
                int degree = random.nextInt(30);
                degrees.add(degree);
                canvas.rotate(degree);
                canvas.drawText(s, 0, textSize, textPaint);
                regions.add(new Region(x, y, textSize + x, textSize + y));
                canvas.restore();
            }
        } else {
            for (int i = 0; i < fonts.length(); i++) {
                String s = String.valueOf(fonts.charAt(fonts.length() - i - 1));
                int textSize = (int) textPaint.measureText(s);

                canvas.save();

                /*效果图上用户点击文字会出现序号显示这是点击的第几个,而验证码文字没有变化,其实验证码文字这里也重新绘制了,
                只不过还是原来的位置、角度*/
                int x = textPoints.get(i).x;
                int y = textPoints.get(i).y;
                int degree = degrees.get(i);
                canvas.translate(x, y);
                canvas.rotate(degree);
                canvas.drawText(s, 0, textSize, textPaint);
                regions.add(new Region(x, y, textSize + x, textSize + y));
                canvas.restore();
            }

            /*绘制点击的序号*/
            for (Point point : tapPoints) {
                int index = tapPoints.indexOf(point) + 1;
                String s = index + "";
                int textSize = width / 6 / 3;
                selectTextPaint.setTextSize(textSize);
                canvas.drawCircle(point.x, point.y, textSize, selectPaint);


                Rect rect = new Rect();
                selectTextPaint.getTextBounds(s, 0, 1, rect);

                int textWidth = rect.width();
                int textHeight = rect.height();

                canvas.drawText(s, point.x - textWidth / 2, point.y + textHeight / 2, selectTextPaint);
            }
        }
    }

    public void reDrew() {
        textPoints.clear();
        degrees.clear();
        tapIndex.clear();
        tapPoints.clear();

        isInit = true;

        invalidate();
    }

    public void setVerifyText(String s){
        fonts = s;

        checkCode = 0;
        int temp = fonts.length() - 1;
        while (temp > -1) {
            checkCode += temp * Math.pow(10, temp);
            temp--;
        }

        invalidate();
    }

    private OnVerifyListener listener;

    public void setVerifyListener(OnVerifyListener listener) {
        this.listener = listener;
    }

}

然后是组合布局,比较简单,这里只放示例代码:
dialog_verify.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="@android:color/white"
    android:gravity="center"
    android:orientation="vertical">

    <com.example.qingfengwei.myapplication.TapVerificationView
        android:id="@+id/tap_verify_view"
        android:layout_width="match_parent"
        android:layout_height="250dp" />

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:gravity="center_vertical"
        android:orientation="horizontal"
        android:padding="5dp">

        <TextView
            android:id="@+id/verify_text"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1" />

        <Button
            android:id="@+id/refresh_verify"
            android:layout_width="25dp"
            android:layout_height="25dp"
            android:background="@mipmap/jyw_refresh" />
    </LinearLayout>
</LinearLayout>

VerifyCationDialog.java

package com.example.qingfengwei.myapplication;

import android.app.Dialog;
import android.content.Context;
import android.text.Html;
import android.util.DisplayMetrics;
import android.view.Gravity;
import android.view.View;
import android.view.Window;
import android.view.WindowManager;
import android.widget.Button;
import android.widget.TextView;

import java.util.Random;


public class VerifyCationDialog extends Dialog {

    private int style;
    private TapVerificationView nofTapVerificationView;
    private Button btnRefresh;
    private TextView tvVerifyCode;

    public VerifyCationDialog(Context context, int style) {
        super(context);
        this.style = style;
        init(context);
    }

    private void init(Context context) {
        requestWindowFeature(Window.FEATURE_NO_TITLE);
        SlidingVerificationView slidingVerificationView = new SlidingVerificationView(context);

        slidingVerificationView.setVerifyListener(new OnVerifyListener() {
            @Override
            public void onResult(boolean isSuccess) {
                if (isSuccess) {
                    dismiss();
                }
                if(listener!=null){
                    listener.onResult(isSuccess);
                }
            }
        });

        if (style == 1) {
            setContentView(R.layout.dialog_verify);
            nofTapVerificationView = findViewById(R.id.tap_verify_view);
            btnRefresh = findViewById(R.id.refresh_verify);
            tvVerifyCode = findViewById(R.id.verify_text);

            setVerifyText();

            nofTapVerificationView.setVerifyListener(new OnVerifyListener() {
                @Override
                public void onResult(boolean isSuccess) {
                    if (isSuccess) {
                        dismiss();
                    } else {
                        setVerifyText();
                    }

                    if(listener!=null){
                        listener.onResult(isSuccess);
                    }

                }
            });

            btnRefresh.setOnClickListener(new View.OnClickListener() {
                @Override
                public void onClick(View v) {
                    setVerifyText();
                    nofTapVerificationView.reDrew();
                }
            });

        } else {
            setContentView(slidingVerificationView);
        }

        DisplayMetrics dm = context.getResources().getDisplayMetrics();
        int displayWidth = dm.widthPixels;
        int displayHeight = dm.heightPixels;
        WindowManager.LayoutParams p = getWindow().getAttributes();  //获取对话框当前的参数值
        if (displayWidth > displayHeight) {
            if (style == 1) {
                p.width = (int) (displayWidth * 0.4);
            } else {
                p.width = (int) (displayWidth * 0.6);
            }

        } else {
            if (style == 1) {
                p.width = (int) (displayWidth * 0.7);
            } else {
                p.width = (int) (displayWidth * 0.9);
            }
        }

        getWindow().setGravity(Gravity.CENTER);
        getWindow().setAttributes(p);
        getWindow().setBackgroundDrawableResource(android.R.color.transparent);
        setCanceledOnTouchOutside(true);
        setCancelable(true);
    }

    private void setVerifyText() {
        String baseText = "悛醍躞稂怙恶瓜沱狖独泗薁臧龉龃腌咄圄砭靁针奉饕瀣圭其罚立轭犄时孓气觎鼯绵顶娜旮醐耋孑蘡娉灌瓞臢臬弊袅龙为呶耄茕行踽觊角旯虺蹀餮沆涕休陟莠轩滂囹不婷否龘嗟";
        Random random = new Random();
        int start = random.nextInt(baseText.length() - 4 - 1);
        int end = start + 4;
        String verifyText = baseText.substring(start, end);
        nofTapVerificationView.setVerifyText(verifyText);
        tvVerifyCode.setText(Html.fromHtml("请依次点击 <font color=\"#FF0000\"><b>" + verifyText + "</b></font>"));
    }


    private OnVerifyListener listener;
    public void setVerifyListener(OnVerifyListener listener){
        this.listener = listener;
    }
}