在做一款需要设置图案锁的应用秘密日记的时候,心血来潮想要实现一个自已的图案解锁控件.于是花了两个小时终于完成.效果还不错,拿出来分享一下实现过程.
自定义的控件,肯定是要继承自View,然后在onDraw()中干活了;但是这里有一个问题就是当手指在界面上拖动时,需要流畅的画出手指位置与最后经过的点的连线.而 View的onDraw()只会被动的画一次,需要不停的调用postInvalidate()去重绘.
所以首先基类我选择了SurfaceView. SurfaceView的好处是可以在后台线程中循环不停的绘制界面,从而能够动态的绘制手指与最后一点的连线.但是坏处也是有的,就是多起了一个线程,浪费了电量.
以上示意图分为三层:
- 最底层是自定义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 mPoints = new ArrayList(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 => value<0>
key
=> value<1>
...
*/
private final Map mCirclePointMap = new HashMap(9);
/*用来反向存储9个圆心的坐标值.以便需要时能够直接查找到某点实际坐标.
map中存值可能如下: key<0> => value< p(3R,3R)>. R= 半径值.
key<1> => value
key<2> => value
3,7,11.步长为4. 3+4=7, 7+4=11.因此还是可以使用for循环来初始化赋值的.
...
*/
private final Map mCirclePointMap2 = new HashMap(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 => value<0>,
key
=> 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