Android自定义View--图案解锁控件PatternLockView

前言:

在做一款需要设置图案锁的应用秘密日记的时候,心血来潮想要实现一个自已的图案解锁控件.于是花了两个小时终于完成.效果还不错,拿出来分享一下实现过程.

分析:

  • 自定义的控件,肯定是要继承自View,然后在onDraw()中干活了;但是这里有一个问题就是当手指在界面上拖动时,需要流畅的画出手指位置与最后经过的点的连线.而 View的onDraw()只会被动的画一次,需要不停的调用postInvalidate()去重绘.

  • 所以首先基类我选择了SurfaceView. SurfaceView的好处是可以在后台线程中循环不停的绘制界面,从而能够动态的绘制手指与最后一点的连线.但是坏处也是有的,就是多起了一个线程,浪费了电量.

  • 在图案解锁界面,实际上9个圆的位置是固定的,可以提前计算出的.因此可以在初始化时提前记录这9个点坐标.示意图如下:
    解锁界面元素

以上示意图分为三层:
- 最底层是自定义View的边框尺寸
- 中间一层为正方形暗红色区域,画9个圆的边框尺寸.
- 最上层是画9个绿色圆形的部分, 为了简化设计,中间正方形区域被7x7的圆形分割,绿色9个圆分别与四周都隔着一个圆的距离.因此有一个统一的尺寸数值: 直径= width/7. 半径= width/14.
- 这里归一化半径有助于后续画圆,以及手指经过时判断是否与圆碰撞有很大的简化.具体在代码中很容易看到.
- 将7x7的圆从左到右,从上到下依次编号(0,0),(1,0),(2,0),…..(0,1),(1,1),(2,1),… (6,6). 而9个绿圆的编号则为:(1,1) , (3,1) ,(5,1) ,(1,3), (3,3), (5, 3), (1,5), (3, 5), (5, 5)
以上规则用于计算手指是否经过某个圆将非常简单.例如控件宽度140,高度200. 则中间绘图区域为正方形140x140. 圆直径= 140/7=20, 圆半径=10.
假如当前手指坐标为 (25, 29). 则 25%直径20= 1, 29%直径20= 1. 得到的归一化坐标为(1,1),可以得出坐标(25,29)在圆(1,1)内. 而坐标(25, 45)=>(1,2) 不在9个圆内.


经过以上分析,我们大概可以实现以下框架结构,直接上代码.

public class PatternLockView extends SurfaceView implements SurfaceHolder.Callback {
    WorkThread workThread;
    //由于最多只有9个点,因此只需要声明9个空间的列表即可.
    private final List<Integer> mPoints = new ArrayList<Integer>(9);
    public PatternLockView(Context context, AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    public PatternLockView(Context context) {
        super(context);
        init();
    }

    public PatternLockView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    //自定义初始化函数.
    private void init() {
        //规定控件的最小尺寸为(100,100),否则以wrap_content显示在界面上时,最小尺寸为0.
        setMinimumHeight(100);
        setMinimumWidth(100);

        //设置背景透明要增加额外设置.
        setZOrderOnTop(true);
        getHolder().setFormat(PixelFormat.TRANSLUCENT);

        //通过设置这个监听器,可以监听到控件初始化完成或销毁的事件.进而控制绘图周期.
        getHolder().addCallback(this);//有了这一句,下面的几个重写函数才回被调用.
    }

 //SurfaceView的绘图界面是要经过一定时间的准备的,因此是异步初始化完成的.要开始绘图必须等控件初始化完成.
    //此函数标识控件可以开始绘图了.
    @Override
    public void surfaceCreated(SurfaceHolder holder) {
        //在里边可以绘图了.一般是创建一个单独的绘图线程,在线程里绘图.
        workThread = new WorkThread();
        workThread.start();
    }

    @Override
    public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
        //控件尺寸变化时会被调用.
    }

    @Override
    public void surfaceDestroyed(SurfaceHolder holder) {
        //控件被回收时会调用.一般是销毁绘图的工作线程.
        if (workThread != null) {
            workThread.setStop();//想要停止线程,不能再使用stop函数了.应该自己实现销毁过程.
            workThread = null;
        }

    }

  private class WorkThread extends Thread {
        private boolean runFlag = true;
         public void setStop() {
            runFlag = false;//下一个循环会自动结束线程.进而销毁线程.
        }
        public void run() {
            Canvas canvas = null;
            long lastTimeMills =0l;
            {
                //code[1] 此处用来写一些变量初始化内容.
            }
             //绘图过程是无限循环,不停重复的.因此要while(true).并不是绘制一次就不再画了.
            while (runFlag) {
                lastTimeMills = System.currentTimeMillis();
                try {
                    //通过在线程中lockCanvas可以获取一个用于绘制界面的画布.
                    canvas = getHolder().lockCanvas();

                    //绘画前先清除上一帧的内容.
                    // 通过在界面上画透明颜色,并设置交插模式为CLEAR.可使界面已有内容消失.类似于橡皮擦.
                    canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);

                    {
                        //将在这里实现整个绘图过程.
                        //code[2].此处用来绘制屏幕内容.

                    }

                    //计算需要休眠多久.以固定33ms的周期来刷新界面,即1s钟画30帧,一般电视画面刷新30帧每秒.
                    timeNeedSleep = 33 - (System.currentTimeMillis() - lastTimeMills);
                    Thread.sleep(timeNeedSleep);

                } catch (Exception e) {
                } finally {//把最后这几句放到finally中去,防止因程序隐藏的bug异常导致canvas没有解锁.而造成绘图崩溃.
                    //绘制完成,释放canvas.
                    if (canvas != null) {
                        getHolder().unlockCanvasAndPost(canvas);
                    }
                }

            }
        }
     }
}

第二步: 监听手指在界面上的触摸事件,记录下触摸的点坐标.以便绘图时使用.
只需要重写View的 onTouchEvent函数即可.

    //当前手指触摸位置的记录.
    private PointF mCurrentFingerPoint = null;

    //监听手指触摸事件.
    public boolean onTouchEvent(MotionEvent e) {
        switch (e.getAction()) {
            //手指按下位置.
            case MotionEvent.ACTION_DOWN: {
                //记录按下位置.只在按下时new一个pointf.
                mCurrentFingerPoint = new PointF(e.getX(), e.getY());
                break;
            }
            //手指在屏幕上移动.
            case MotionEvent.ACTION_MOVE: {
                //记录移动位置.move时,修改pointf的值.
                mCurrentFingerPoint.set(e.getX(), e.getY());
                break;
            }
            //手指在屏幕上抬起.表示此次解锁操作结束.
            case MotionEvent.ACTION_UP: {
                //抬起手指时,清除当前位置.
                mCurrentFingerPoint = null;
                break;
            }
        }
        return true;
    }

第三步: 声明属性用来记录9个圆的圆心坐标以及归一化坐标.用于绘图时的直接使用,以及检测手指碰撞时的查找过程.

 /*用map来记录下9个点的固定坐标归一值.9个圆依次编号0-8,生成密码时即生成0-8这几个数字的组合. map中的值如下: 
 key<p(1,1)> => value<0>
 key<p(3,1)> => value<1>
 ...
*/
 private final Map<Point, Integer> mCirclePointMap = new HashMap<Point, Integer>(9);

 /*用来反向存储9个圆心的坐标值.以便需要时能够直接查找到某点实际坐标.
 map中存值可能如下: key<0> => value< p(3R,3R)>. R= 半径值.
 key<1> => value<p(7R,3R)>
 key<2> => value<p(11R,3R)> 3,7,11.步长为4. 3+4=7, 7+4=11.因此还是可以使用for循环来初始化赋值的.
 ...
 */
 private final Map<Integer, PointF> mCirclePointMap2 = new HashMap<Integer, PointF>(9);

第四步: 初始化以上两个map的内容. 具体在哪儿写初始化呢? 应该在开始绘制界面之前就初始化好了的.而且只需要初始化一次,以后直接使用就可以了.因此放在WorkThread的run函数中. 代码中标了code[1]的位置.即while(runFlag)的前面.
代码如下:

  public void run() {
            Canvas canvas = null;
 //得到的是整个View界面的尺寸及位置.经过刚才的测试,这个也是不准确的..留待解决.
Rect viewBound = getHolder().getSurfaceFrame();
//根据最小边长,计算出一个正方形.
int width = viewBound.width();
if (viewBound.height() < width) {//如果高度小于width,则以高度做为正方形的边长.
       width = viewBound.height();
 }
            int width_2 = width / 2;
            int width_7 = width / 7;//直径大小.
            float width_14 = width / 14.0f;//半径大小.

            //由最小边长即可获得一个正方形区域.
            Rect realBound = new Rect(
                viewBound.centerX() - width_2, 
                viewBound.centerY() - width_2,
                viewBound.centerX() + width_2, 
                );

            /*初始化mCirclePointMap.k值表示是第几个圆.
            得出的内容是:
             key<p(1,1)>  => value<0>, 
             key<p(3,1)>  => value<1>,
             ...
            */
            for (int i = 1, k = 0; i < 6; i += 2) {
                for (int j = 1; j < 6; j += 2) {
                    //思考为什么是p(j,i)而不是(i,j);
                    mCirclePointMap.put(new Point(j, i), k++);
                }
            }
            /*初始化mCirclePointMap2.k值表示是第几个圆.
            * j,i = 3,7,11. 的组合.3R,7R,11R.
            * 坐标值都加上了正方形的偏移值.left&top.
            */
            for (int i = 3, k = 0; i < 14; i += 4) {
                //每列.画9个圆时用到了这个循环.
                for (int j = 3; j < 14; j += 4) {
                    //画圆时用到了这9个坐标值..
                    mCirclePointMap2.put(k++, new PointF(j * width_14 + realBound.left, i * width_14 + realBound.top));
                }
            }

第五步: 构造小三角形.
通常的图案解锁界面,会标出两个圆之间的方向–在连线中间画一个小三角形来标示.那么这个三角形如何画呢.其实很简单:只需要用三个顶点构造一个封闭路径即可.代码接上面代码:

//用path圈出来一个水平方向的三角形路径.坐标点分别为:(0,10),(0,-10),(10,0)
//思考:如果想要个正三角形,值该如何修改.
Path trianglePath = new Path();
trianglePath.moveTo(width_7, 10);//让三角形起始位置偏移一个距离,不再紧靠圆心位置.
trianglePath.lineTo(width_7, -10);
trianglePath.lineTo(width_7 + 17.32f, 0);//修改10=>1.732*10 = 17.32.可以得到一个正三角.
trianglePath.close();

这个三角形是水平指向右方的,但是很多时候连线都是斜着的,这又该如何做呢.别急,我们都知道canvas对象在画图的时候是可以对画布进行旋转等操作的.只要让画布沿着连线的方向转相应的角度,我们水平向右的三角就变成指向连线的方向了.具体在代码中自行体会.


第六步:真正的画图操作开始啦~~
这一步要完成什么呢?? 画圆,画连线! 上代码:

//在code[2]的位置.插入代码.即在while循环中画这些内容.
//用循环的方式画9个圆.
//每行.
for (int i = 3; i < 14; i += 4) {
//每列.
    for (int j = 3; j < 14; j += 4) {
 //实际起始位置应该是实际区域realBound的起始位置算起.所以x,y应该分别加上left,top.
        canvas.drawCircle(j * width_14 + realBound.left, 
                          i * width_14 + realBound.top, 
                          width_14, 
                          mCirclePaint);
     }
}

//画完9个圆了,再在检测手指当前位置是否在9个圆内.
//声明一个点,用来存储手指位置归一后的坐标值.p(0,1),p(1,1)...
Point fingerPoint = new Point(0, 0);//此点应该声明在while外面,否则每个循环都要创建一个此点,显示浪费.

//当mCurrentFingerPint不为空时,说明手指正在屏幕上.
if (mCurrentFingerPoint != null) {
     fingerPoint.x = (int) (mCurrentFingerPoint.x - realBound.left) / width_7;
     fingerPoint.y = (int) (mCurrentFingerPoint.y - realBound.top) / width_7;
//如果x,y表示的点在map中,则number不为空.可以知道手指在某一个圆中.
Integer number = mCirclePointMap.get(fingerPoint);
if (number != null) {
    //说明经过了某个点.需要把这个点记录下来.因此需要声明一个List来记录这些点值,在view中声明此属性.
   if (mPoints.contains(number)) {
       //如果已经包含了此点,说明手指已经经过了这个点,不需要再放进来了.
   } else {
       mPoints.add(number);//否则就是第一次经过此圆,需要加入序列.
     }
   }
 }

//!!! 再然后,将所有经过的圆的圆心依次连接起来,形成图案.
//把这一过程拿到mCurrentFinger!=null外边.
//在所有经过的圆圈的圆心上画一个实心的小圆,表示经过了这个圆.
 if (mPoints.isEmpty()) {
     //如何控制连线自动消失.
 } else {
    PointF prevPoint = null, currPoint = null;
 //需要得到每个圆圈的圆心坐标.如何得到.mPoints中存储的是某个点的归一值.
 // 需要通过建立映射关系来查找实际的圆心坐标.使用Map来存储.
 mCirclePaint.setStrokeWidth(4);
 for (Integer p : mPoints) {
      //通过归一值来查找点的实际坐标对象.
      currPoint = mCirclePointMap2.get(p);
      canvas.drawCircle(currPoint.x, currPoint.y, 16, mFillPaint);
      canvas.drawCircle(currPoint.x, currPoint.y, width_14, mCirclePaint);
      //将每两个点进行连线.需要知道上一个点和当前点.
      if (prevPoint != null) {
      //如果上一个点坐标是空的,说明是第一个点.
      canvas.drawLine(prevPoint.x, prevPoint.y, currPoint.x, currPoint.y, mFillPaint);
      //在画线的位置,相应的画三角形.思考如何使三角形正确的朝向.
      //drawTriangle();画小三角
      {
         canvas.save();
         //通过旋转画布,使画出的三角形与连线垂直.
         //计算需要旋转的角度. atan2 得到的是弧度值.还需要转换为角度值.
          double angle = Math.atan2(currPoint.y - prevPoint.y, currPoint.x - prevPoint.x);
          canvas.rotate((float) Math.toDegrees(angle), prevPoint.x, prevPoint.y);
          canvas.translate(prevPoint.x, prevPoint.y);
          canvas.drawPath(trianglePath, mFillPaint);
          canvas.restore();
       }
     }
     //画完了,当前点就变成上一个点了.
     prevPoint = currPoint;
  }

  mCirclePaint.setStrokeWidth(2);
  //应该在for循环外边画这最后一条线.
  //绘制最后一个经过的圆心到手指间的连线.
  if (mCurrentFingerPoint != null && currPoint != null) {
      canvas.drawLine(currPoint.x, currPoint.y, 
          mCurrentFingerPoint.x, 
          mCurrentFingerPoint.y, mFillPaint);
    }
}

绘制过程完成.
最后一步:给控件加入监听事件.监听什么呢? 当手指滑动结束后将生成一个字符串密码,需要传递给外界. 同时外界得到密码后,还需要判断密码是否正确,从而控制界面绘制红色错误圆和线条.

监听函数代码如下:

//手指抬起的监听器.应声明在view类中.
    public interface OnUnlockListener {
        //监听器传入手指划动后形成的图形密码值.监听器处理后告诉控件,密码是否是确.由此来显示红色图案.
        public boolean onUnlock(String password);
    }

    private OnUnlockListener mUnlockListener = null;

    public void setOnUnlockListener(OnUnlockListener l) {
        mUnlockListener = l;
    }

在手指抬起的动作中处理此监听函数,因此改写onTouchEvent的UP事件响应:

//手指在屏幕上抬起.表示此次解锁操作结束.
   case MotionEvent.ACTION_UP: {
      //抬起手指时,清除当前位置.
      mCurrentFingerPoint = null;
      //手指抬起,传递相应的数据出去,适合使用监听器实现.
      if (mUnlockListener != null) {
         //密码该是什么呢?
         String password = mPoints.toString();//直接将数组的值转换为字符串传递出去.
         //接收外界传过来的对错结果.
         isCorrect = mUnlockListener.onUnlock(password);
         //当密码正确时,立即清除mPoints,不再让界面画线了.否则就计时2s后再清除.
         if (isCorrect) {
                mPoints.clear();
          }
      }
    break;

在view中声明一个属性: boolean isCorrect = true;
用来保存上一次的结果是否正确.如果结果正确,则界面立即恢复正常,而如果结果为false.则需要界面图案变为红色,同时过1秒后再恢复正常.
代码逻辑如下:
在workThread的run函数中处理,因为每隔33ms一个循环,因此当记数30次后,相当于是过了1秒.

whie(runFlag){
    //...省略部分代码.
    //画圆之前判断对错.然后修改画笔颜色.
     if (isCorrect) {
          mCirclePaint.setColor(COLOR_NORMAL);
          mFillPaint.setColor(COLOR_NORMAL);
          correctTimer = 0;
     } else {
        mCirclePaint.setColor(COLOR_ERROR);
        mFillPaint.setColor(COLOR_ERROR);
        correctTimer++;//当错误时,timer负责计时.
        if (correctTimer >= 30) {
            //计时过了30帧了.即1秒钟了.
            isCorrect = true;
            correctTimer = 0;
            mPoints.clear();//清除连线的坐标记录.
          }
     }
    //...省略部分代码
}

最后是使用过程:
此控件可以像普通View一样在xml布局中声明.

<cn.andrewlu.app.securediary.PatternLockView
            android:id="@+id/lockView"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:layout_gravity="center" />

代码中使用:

PatternLockView lockView = (PatternLockView)findViewById(R.id.lockView);
lockView.setOnUnlockListener(new OnUnlockListener(){
    public boolean onUnlock(String paswd){
        //比较密码,或记录密码.
        if(passed.equals(lastPaswd)){//与一次形成的密码一致.
            return true;
        }else{
            return false;//密码错误.
        }

    }
});

贴图真不好贴啊.没有图,完全不生动啊.最好能截图,直接粘贴到博客里.

最后,完整的项目代码传送门:Github PatternLockView.java

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值