如下图:
首先我们考虑在哪里完成点和线的绘图
通常我们想到的是写一个自定义的View(即继承自View类),添加onTouchEvent进行控制,同时覆写onDraw()方法,完成绘制。不过我这里没有采用这种方式,考虑到onTouchEvent只能接收在View之上的触摸事件,从上面第一张图中可以看出,如果文字和自定义View平铺摆放的话,那么当手指滑动到文字上面的时候,已经超出了自定义View的范围,因此无法响应触摸事件。虽说有一种补救方式,就是让其他控件和自定义View叠在一起,即摆放在一个FrameLayout里面,不过帧布局对控件位置的控制不像RelativeLayout这样灵活,因此我的实现方式是自定义RelativeLayout,并且在dispatchDraw()方法里,完成点和线的绘制。dispatchDraw()会在布局绘制子控件时调用,具体的可以参考谷歌官方文档。
首先需要有一个类来记录九个圆点的基本信息。我们可以视为这九个圆是分布于3*3的方格子里面,其中每一个圆位于方格子的中心,在绘制这些圆时,有以下基本信息是要知道的:
1、这些方格子的位置(左上角的X,Y坐标)
2、方格子的边长有多大?
3、方格子的边到圆的边有多大的间隔?
4、圆心的位置(圆心X,Y坐标)
5、圆的半径是多少?
6、这个圆当前应该显示什么颜色?(即圆点的状态)
7、由于我们不可能记录图案整体,而是记录连接点的顺序,那么这个圆所表示的密码值是多少?
不过上面这7个值是相互依赖的,比如我知道了1和2,就能知道4;知道了2和3,就能知道5。因此,在定义这些值的时候,应当让用户提供充分但不冲突的信息(比如我这里从外部获取的是1、2、3、6、7,而4和5是算出来的)。我在实现的时候,把定义下来就再也用不到的信息写在了一个类里面,把绘制点时还需要获取的信息写在了另一个类里面,并且这个类提供了一些外部调用的方法(实际上这两个类合二为一是完全合理的),代码如下
package xiangcuntiandi.mylock1;
/**
* Créé par liusiqian 15/12/17.
*/
public class PatternPoint extends PatternPointBase
{
protected static final int MIN_SIDE = 20; //最小边长
protected static final int MIN_PADDING = 4; //最小间隔
protected static final int MIN_RADIUS = 6; //最小半径
protected int left, top, side, padding; //side:边长
public PatternPoint(int left, int top, int side, int padding, String tag)
{
this.left = left;
this.top = top;
this.tag = tag;
if (side < MIN_SIDE)
{
side = MIN_SIDE;
}
this.side = side;
if (padding < MIN_PADDING)
{
padding = MIN_PADDING;
}
radius = side / 2 - padding;
if (radius < MIN_RADIUS)
{
radius = MIN_RADIUS;
padding = side / 2 - radius;
}
this.padding = padding;
centerX = left + side / 2;
centerY = top + side / 2;
status = STATE_NORMAL;
}
}
package xiangcuntiandi.mylock1;
/**
* Créé par liusiqian 15/12/18.
*/
public abstract class PatternPointBase
{
protected int centerX; //圆心X
protected int centerY; //圆心Y
protected int radius; //半径
protected String tag; //密码标签
public int status; //状态
public static final int STATE_NORMAL = 0; //正常
public static final int STATE_SELECTED = 1; //选中
public static final int STATE_ERROR = 2; //错误
public int getCenterX()
{
return centerX;
}
public int getCenterY()
{
return centerY;
}
public boolean isPointArea(double x, double y)
{
double len = Math.sqrt(Math.pow(centerX - x, 2) + Math.pow(centerY - y, 2));
return radius > len;
}
public String getTag()
{
return tag;
}
public int getRadius()
{
return radius;
}
}
可以看到,在基类里面定义了圆点的状态常量。此外还提供了一个方法叫做isPointArea(),这个方法用于判断对于给定的一个点,它是否在这个圆之内。我们在进行连线时,如果经过了一个点,则需要把它连接起来,这时需要用到这个函数。
接下来是这个扩展的RelativeLayout,这里先给出整个类的代码,然后再逐步解释。
package xiangcuntiandi.mylock1;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.os.Handler;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.widget.RelativeLayout;
import java.util.ArrayList;
/**
* Créé par liusiqian 15/12/17.
*/
public class PatternLockLayout extends RelativeLayout
{
public PatternLockLayout(Context context)
{
super(context);
}
public PatternLockLayout(Context context, AttributeSet attrs)
{
super(context, attrs);
}
public PatternLockLayout(Context context, AttributeSet attrs, int defStyleAttr)
{
super(context, attrs, defStyleAttr);
}
private boolean hasinit; //初始化是否完成
private PatternPoint[] points = new PatternPoint[9]; //九个圆圈对象
private int width, height, side; //布局可用宽,布局可用高,小方格子的边长
private int sidePadding, topBottomPadding; //侧边和上下边预留空间
private boolean startLine; //是否开始连线
private boolean errorMode; //连线是否使用表示错误的颜色
private boolean drawEnd; //是否已经抬手
private boolean resetFinished; //重置是否已经完成(是否可以进行下一次连线)
private float moveX, moveY; //手指位置
private ArrayList<PatternPoint> selectedPoints = new ArrayList<>(); //所有已经选中的点
private static final int PAINT_COLOR_NORMAL = 0xffcccccc;
private static final int PAINT_COLOR_SELECTED = 0xff00dd00;
private static final int PAINT_COLOR_ERROR = 0xffdd0000;
private Handler mHandler;
@Override
protected void dispatchDraw(Canvas canvas)
{
super.dispatchDraw(canvas);
if (!hasinit)
{
//暂时写死,后面通过XML设置
sidePadding = 40;
topBottomPadding = 40;
initPoints();
resetFinished = true;
}
this.paint1 = new Paint();
this.paint1.setAntiAlias(true); //消除锯齿
this.paint1.setStyle(Paint.Style.STROKE);
drawCircle(canvas);
drawLine(canvas);
}
Paint paint1;
@Override
public boolean onTouchEvent(MotionEvent event)
{
moveX = event.getX();
moveY = event.getY();
switch (event.getAction())
{
case MotionEvent.ACTION_DOWN:
{
int index = whichPointArea();
if (-1 != index && resetFinished)
{
addSelectedPoint(index);
startLine = true;
}
}
break;
case MotionEvent.ACTION_MOVE:
{
if (startLine && resetFinished)
{
int index = whichPointArea();
if (-1 != index && points[index].status == PatternPointBase.STATE_NORMAL)
{
//查看是否有中间插入点
insertPointIfNeeds(index);
//增加此点到队列中
addSelectedPoint(index);
}
}
}
break;
case MotionEvent.ACTION_UP:
{
if (startLine && resetFinished)
{
resetFinished = false;
int delay = processFinish();
mHandler.postDelayed(new Runnable()
{
@Override
public void run()
{
reset();
}
}, delay);
}
}
break;
}
invalidate();
return true;
}
public void setAllSelectedPointsError()
{
errorMode = true;
for (PatternPoint point : selectedPoints)
{
point.status = PatternPointBase.STATE_ERROR;
}
invalidate();
}
private void reset()
{
for (PatternPoint point : points)
{
point.status = PatternPointBase.STATE_NORMAL;
}
selectedPoints.clear();
startLine = false;
errorMode = false;
drawEnd = false;
if (listener != null)
{
listener.onReset();
}
resetFinished = true;
invalidate();
}
//返回值为reset延迟的毫秒数
private int processFinish()
{
drawEnd = true;
if (selectedPoints.size() < 2)
{
return 0;
}
else //长度过短、密码错误的判断留给外面
{
int size = selectedPoints.size();
StringBuilder sbPassword = new StringBuilder();
for (int i = 0; i < size; i++)
{
sbPassword.append(selectedPoints.get(i).tag);
}
if (listener != null)
{
listener.onFinish(sbPassword.toString(), size);
}
return 1000;
}
}
public interface OnPatternStateListener
{
void onFinish(String password, int sizeOfPoints);
void onReset();
}
private OnPatternStateListener listener;
public void setOnPatternStateListener(OnPatternStateListener listener)
{
this.listener = listener;
}
private void insertPointIfNeeds(int curIndex)
{
final int[][] middleNumMatrix = new int[][]{{-1, -1, 1, -1, -1, -1, 3, -1, 4}, {-1, -1, -1, -1, -1, -1, -1, 4, -1}, {1, -1, -1, -1, -1, -1, 4, -1, 5}, {-1, -1, -1, -1, -1, 4, -1, -1, -1}, {-1, -1, -1, -1, -1, -1, -1, -1, -1}, {-1, -1, -1, 4, -1, -1, -1, -1, -1}, {3, -1, 4, -1, -1, -1, -1, -1, 7}, {-1, 4, -1, -1, -1, -1, -1, -1, -1}, {4, -1, 5, -1, -1, -1, 7, -1, -1}};
int selectedSize = selectedPoints.size();
if (selectedSize > 0)
{
int lastIndex = Integer.parseInt(selectedPoints.get(selectedSize - 1).tag) - 1;
int middleIndex = middleNumMatrix[lastIndex][curIndex];
if (middleIndex != -1 && (points[middleIndex].status == PatternPointBase.STATE_NORMAL) && (points[curIndex].status == PatternPointBase.STATE_NORMAL))
{
addSelectedPoint(middleIndex);
}
}
}
private void addSelectedPoint(int index)
{
selectedPoints.add(points[index]);
points[index].status = PatternPointBase.STATE_SELECTED;
}
/**
* 点的区域大小
* @return
*/
private int whichPointArea()
{
for (int i = 0; i < 9; i++)
{
if (points[i].isPointArea(moveX+10, moveY+10))
{
return i;
}
}
return -1;
}
/**
* 画线
* @param canvas
*/
private void drawLine(Canvas canvas)
{
Paint paint = getCirclePaint(errorMode ? PatternPoint.STATE_ERROR : PatternPoint.STATE_SELECTED);
paint.setStrokeWidth(15);
for (int i = 0; i < selectedPoints.size(); i++)
{
if (i != selectedPoints.size() - 1) //连接线
{
PatternPoint first = selectedPoints.get(i);
PatternPoint second = selectedPoints.get(i + 1);
canvas.drawLine(first.getCenterX(), first.getCenterY(),
second.getCenterX(), second.getCenterY(), paint);
}
else if (!drawEnd) //自由线,抬手之后就不用画了
{
PatternPoint last = selectedPoints.get(i);
canvas.drawLine(last.getCenterX(), last.getCenterY(),
moveX, moveY, paint);
}
}
}
/**
* 画圆
* @param canvas
*/
private void drawCircle(Canvas canvas)
{
for (int i = 0; i < 9; i++)
{
//圆的中心点
PatternPoint point = points[i];
Paint paint = getCirclePaint(point.status);
//画内圆
canvas.drawCircle(point.getCenterX(), point.getCenterY(), points[i].getRadius(), paint);
//画外圆环
canvas.drawCircle(point.getCenterX(), point.getCenterY(), points[i].getRadius()+20,paint1);
}
}
/**
* 初始化点
*/
private void initPoints()
{
width = getWidth() - getPaddingLeft() - getPaddingRight() - sidePadding * 2;
height = getHeight() - getPaddingTop() - getPaddingBottom() - topBottomPadding * 2;
//使用时暂定强制竖屏
int left, top;
left = getPaddingLeft() + sidePadding;
top = height + getPaddingTop() + topBottomPadding - width;
side = width / 3;
for (int i = 0; i < 3; i++)
{
for (int j = 0; j < 3; j++)
{
int leftX = left + j * side;
int topY = top + i * side;
int index = i * 3 + j;
points[index] = new PatternPoint(leftX, topY, side, side / 3, String.valueOf(index + 1));
}
}
mHandler = new Handler();
hasinit = true;
}
/**
* 得到画笔的颜色
* @param state
* @return
*/
private Paint getCirclePaint(int state)
{
Paint paint = new Paint();
switch (state)
{
case PatternPoint.STATE_NORMAL:
paint.setColor(PAINT_COLOR_NORMAL);
break;
case PatternPoint.STATE_SELECTED:
paint.setColor(PAINT_COLOR_SELECTED);
break;
case PatternPoint.STATE_ERROR:
paint.setColor(PAINT_COLOR_ERROR);
break;
default:
paint.setColor(PAINT_COLOR_NORMAL);
}
return paint;
}
}
首先先绘制布局中的其他控件,它们与图案锁没有任何关系。接下来分为3步:
1、初始化。参见initPoints()方法,其作用为创建九个PatternPoint对象,并确定每一个圆的位置和密码。我们之前说视为这九个圆位于3*3的方格子中,不过这3*3的方格子不一定要紧贴着布局的边界,因此定义了两个变量sidePadding和topBottomPadding,用于记录方格子与布局边界之间的距离。不过我这里图省事儿直接将这两个值写死了,实际上最妥当的方案是在attrs.xml中定义这两个属性,然后在布局xml中定义这两个属性的值,最后在源文件中获取这两个属性,并且将它们的值赋值给变量。此外需要注意的是,初始化代码只需执行一次就够了,而dispatchDraw()会反复调用,因此需要一个控制变量记录初始化是否完毕。
2、画圆。这个比较简单,根据不同圆当前处于的状态进行绘制即可。参见drawCircle()和getCirclePaint()方法。
3、画线。这是最复杂的一部分,实现部分在drawLine()方法中,首先我们需要知道要画的是哪个颜色的线。从上面的效果展示可知,线的颜色一共分为两种:正在连线时和连线正确时是同一种颜色,另外就是连线错误时的颜色。这里需要使用一个变量记录当前是否处于连线错误状态,并且根据这个变量的值去获取不同的画笔(Paint对象)。
前面说过,连线分为两部分,一部分是点和点之间的连线(我们称之为连接线),另一部分是最后一个点和当前手指的位置的连线(我们称之为自由线)。无论是连接线还是自由线,都需要知道我之前所有连接过的点的顺序,因此需要一个ArrayList来记录它。在绘制自由线的时候,需要知道当前手指的位置(X,Y坐标),这两个值是在onTouchEvent()中获取的,因此需要两个类变量记录它。此外,当我的手抬起来之后,表示我的一次连线已经结束了,这时是不需要绘制自由线的,因此这里要额外加一个判断。
接下来分析一下触摸事件,它的设计思路大致如下:
1、在按下时,如果我手指的位置正处于某个点中,那么一次连线开始,并且把这个点加入到选中点的List中,作为第一个点。
2、在移动时,如果我已经开始连线,那么需要明确的是我的选中列中至少已经有一个点了(至少会有一个起始点)。此时需要判断是否经过了某一个点,并且这个点是还没有进入选中列中的点。在满足这些条件之后,进行下面判断:
a)查看我上一个连接的点和这次经过的点中间是否需要插入点(比如上一个点是左上角的点,这里经过的点是右上角的点,并且正上方的点还没有进入选中列,此时,应当将正上方的点加入到选中列中,并且在右上角这个点之前插入)
b)增加这个经过的点到选中列中。
3、在抬起时,如果我已经开始连线,表明我这次连线结束了。这时如果存在连接线而不是仅仅有自由线(即选中列中的点至少有两个),则去计算这个图案对应的密码,提供给外部进行密码长度和密码正误的判断。既然说到要给外部进行回调,因此需要提供一个接口。
4、在每当发生触摸事件之后,都重新绘制连线。
下面强调几个特殊的方法。
1、insertPointIfNeeds(),这个方法用于上面说的触摸事件中2a这个步骤,判断两个点中间是否需要插入额外的一个点到选中队列中。我在程序里把9个点从左到右,从上到下分别标为1-9。那么1和3中需要插入2,4和6中需要插入5等等这些判断,我通过一个常量矩阵进行获取,这样就避免了大片的if,else。矩阵中的值表示需要插入点的index值,-1表示没有。当然有这样的点不一定就表示需要插入到选中列中,还需要满足当前经过的点和中间插入的点之前都没有在选中列中的条件。
2、setAllSelectedPointsError(),这个方法提供给外部Activity调用,当用户判断出图案密码太短或者图案密码错误时,将所有选中列中的点的状态设为错误状态,同时,将连线的颜色设为错误时连线的颜色。注意设置完成之后需要重绘。
3、processFinish(),这个方法主要说一下返回值,从程序中可以看出,它的返回值是一个时间值。因为当用户连线完成之后,无论其连线正确与否,都需要将这个连线图案保持一段时间,而并不是瞬间就恢复到初始状态。
4、reset()方法和resetFinished变量,reset()的作用是将所有记录状态的值都恢复到初始化完成的状态,随后将resetFinished置为true。而在resetFinished为false时,按下、移动、抬起这些触摸事件都是不起作用的。之前说过,当用户连线完成之后,需要保持图案一定时间,而这段时间之内,是不允许用户进行连线的,resetFinished变量的作用就是控制这个部分。reset()方法中,当所有变量都重置之后,又给外部提供了一个回调方法,它的作用是告诉Activity已经重置完成,如果Activity中有关于密码正误判断的显示,则可在这个回调中进行重置。
在activity中界面代码如下:
public class LockActivity extends Activity implements OnPatternStateListener{
private TextView tvInfo;
private PatternLockLayout lockLayout;
@Override
protected void onCreate(Bundle savedInstanceState)
{
super.onCreate(savedInstanceState);
setContentView(R.layout.lock);
tvInfo = (TextView) findViewById(R.id.txt_patternlock_info);
tvInfo.setText("请绘制图案密码");
lockLayout = (PatternLockLayout) findViewById(R.id.layout_lock);
lockLayout.setOnPatternStateListener(this);
TextView tView=(TextView) findViewById(R.id.txt_patternlock);
tView.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
// TODO Auto-generated method stub
MySPUtils.clear(getApplicationContext());
MyApplication.getInstance().exit();
// 极光推送消息注册别名 调用 Handler 来异步设置别名
Intent intent = new Intent(LockActivity.this, MainActivity.class);
startActivity(intent);
finish();
SpUtils.remove(getApplicationContext(), Constant.userId+"");
}
});
}
int anInt=0;
String psw;
//这段代码完整的实现了qq和支付宝屏幕锁的功能
同一个手机不同的账号可以设置不同的密码:并且数据持久化:
@Override
public void onFinish(String password, int sizeOfPoints)
{
if (TextUtils.isEmpty(SpUtils.getString(getApplicationContext(),Constant.userId+"",""))){
if(sizeOfPoints<4)
{
tvInfo.setText("请连接至少4个点");
lockLayout.setAllSelectedPointsError();
}else {
anInt++;
tvInfo.setText("请在输入一遍密码");
psw=SpUtils.getString(getApplicationContext(),"pass","");
if (TextUtils.isEmpty(psw)){
SpUtils.putString(getApplicationContext(),"pass",password);
}else {
if (password.equals(psw)&&anInt==2){
anInt=0;
tvInfo.setText("密码设置成功");
SpUtils.putString(getApplicationContext(),Constant.userId+"",password);
finish();
}else if (anInt==2){
anInt=0;
tvInfo.setText("密码不一致");
SpUtils.remove(getApplicationContext(),"pass");
}
}
}
return;
}else if (!TextUtils.isEmpty(SpUtils.getString(getApplicationContext(),Constant.userId+"",""))){
if(sizeOfPoints<4)
{
tvInfo.setText("请连接至少4个点");
lockLayout.setAllSelectedPointsError();
}
else if( !password.equals(SpUtils.getString(getApplicationContext(),Constant.userId+"","")) )
{
tvInfo.setText("图案密码错误");
ante++;
if (ante==5) {
ante=0;
lockLayout.setAllSelectedPointsError();
MySPUtils.clear(getApplicationContext());
MyApplication.getInstance().exit();
// 极光推送消息注册别名 调用 Handler 来异步设置别名
Intent intent = new Intent(this, MainActivity.class);
startActivity(intent);
finish();
SpUtils.remove(getApplicationContext(), Constant.userId+"");
}
}
else
{
tvInfo.setText("图案正确");
finish();
}
}
}
int ante=0;
@Override
public void onReset()
{
tvInfo.setText("请绘制图案密码");
}
@Override
public void onBackPressed() {
// TODO Auto-generated method stub
super.onBackPressed();
Intent intent = new Intent(this, MainActivity.class);
startActivity(intent);
}