編輯:關於Android編程
很多品牌的Android手機都實現了圖案解鎖屏幕的功能,有些應用程序出於保護的目的也使用了圖案鎖(比如支付寶),本文將介紹一種圖案鎖的實現方式,這種實現的一個優勢在於方便擴展和自定義,我們先看一下效果圖。
首先是連線階段,整個連線為兩部分:第一部分是點和點之間的固定線段,第二部分是最後一個點到鼠標移動位置的自由線段。
接下來是連線結束之後,需要判斷圖案是否正確,我這裡暫時寫死的Z字形為正確圖案,實際應用時需要記錄用戶的輸入為設置的圖案密碼。
首先我們考慮在哪裡完成點和線的繪圖。通常我們想到的是寫一個自定義的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 com.liusiqian.patternlock;
/**
* 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;
}
}
package com.liusiqian.patternlock;
/**
* Créé par liusiqian 15/12/18.
*/
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;
}
}
可以看到,在基類裡面定義了圓點的狀態常量。此外還提供了一個方法叫做isPointArea(),這個方法用於判斷對於給定的一個點,它是否在這個圓之內。我們在進行連線時,如果經過了一個點,則需要把它連接起來,這時需要用到這個函數。
接下來是這個擴展的RelativeLayout,這裡先給出整個類的代碼,然後再逐步解釋。
package com.liusiqian.patternlock;
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/18.
*/
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 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;
}
drawCircle(canvas);
drawLine(canvas);
}
@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 2000;
}
}
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;
}
private int whichPointArea()
{
for (int i = 0; i < 9; i++)
{
if (points[i].isPointArea(moveX, moveY))
{
return i;
}
}
return -1;
}
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);
}
}
}
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);
}
}
private void initPoints()
{
width = getWidth() - getPaddingLeft() - getPaddingRight() - sidePadding * 2;
height = getHeight() - getPaddingTop() - getPaddingBottom() - topBottomPadding * 2;
//使用時暫定強制豎屏(即認定height>width)
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;
}
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;
}
}
先梳理一下流程。首先是繪制,在dispatchDraw()方法中的代碼如下:
@Override
protected void dispatchDraw(Canvas canvas)
{
super.dispatchDraw(canvas);
if (!hasinit)
{
//暫時寫死,應該通過XML設置
sidePadding = 40;
topBottomPadding = 40;
initPoints();
resetFinished = true;
}
drawCircle(canvas);
drawLine(canvas);
}
首先先繪制布局中的其他控件,它們與圖案鎖沒有任何關系。接下來分為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中有關於密碼正誤判斷的顯示,則可在這個回調中進行重置。
最後附帶上這個擴展的RelativeLayout的使用,即Activity和對應的xml布局中的代碼,這部分很容易理解,就不解釋了。
<com.liusiqian.patternlock.PatternLockLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/layout_lock"
android:layout_width="match_parent"
android:layout_height="match_parent">
"@+id/txt_patternlock_info"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:text="信息"
android:textSize="28sp"
android:layout_marginTop="60dp"/>