我個人對Android原生系統是比較鍾愛的。Android原生手勢鎖只有點和線構成,也將扁平化做到了極致。今天我就來通過自定義控件的形式純手工打造一個高仿的原生手勢鎖控件。
android 原生手勢鎖效果圖及描述
原生效果圖 :
原生解鎖的整體預覽效果
一些細節展示
我們首先來描述一下我們要實現的內容。有時候程序員如果先把問題描述清楚,解決問題的時候也會事半功倍。
先說靜止的,就是9個白點組成的3X3矩陣,相鄰白點之間的間距相同。
變化的內容,變化是由於手指的觸摸事件引起的。每個點在其周圍都有一個‘領域’範圍,如果手指移動到了這個領域內就會觸發該點‘被選中’的事件,效果是引起該點的一個放大然後縮小的動畫,並且該點也被連入一個特定的路徑中,如果路徑中穿過了一個未被連接入路徑的點,那麼這個點會被自動接入到該路徑。手指從上一個接入手勢路徑的點離開的時候有一個從該點到手指觸點的一個線段,觀察得知,這個多出來的線段在一定範圍s內是不顯示的,當線段長度從s到2s變化的時候線段的透明度也會從0變爲1。手指擡起,如果密碼正確手勢鎖消失,否則改變錯誤的路徑顏色。
先看下我們自定義控件實現的效果。
整體效果
控件細節
Android原生手勢鎖邏輯分析
根據描述我們需要在view的onDraw方法中畫的東西分兩部分:
不變的部分,9個點,可以用一個數組表示。
變化的部分,即手指移動產生的手勢路徑,這個路徑包含三部分:路徑經過的點,點與點之間連線的路徑,還有手指移動時候多出來的一段路徑。所以變化的部分我們封裝入一個類PWDPath 中。
其中相應觸摸操作的是第二部分,只需要監聽onTouchEvent事件然後改變PWDPath 對象的內容,然後調用invalidate()即可。
繪製內容的尺寸顯然跟控件大小有關,我們把這些尺寸的確定放在控件尺寸確定之後。爲了方便我們把view的形狀設置成正方形。然後根據邊長設置內容部分的尺寸(點半徑、點間距等)。
密碼的驗證:我們預先指定點所對應 的數字如下,根據密碼的連線的到對應的數字序列字符串,與我們保存的密碼比較即可。
代碼部分
由於代碼中註釋很清楚,就不在詳細說明了,自定義兩個屬性,正常顏色NORMAL_COLOR和錯誤顏色ERROR_COLOR方便定製。
package com.sovnem.originallock;
//省去一畝炮特部分
/**
* 視圖結構爲3X3的矩陣點集合,點周圍一定區域爲有效範圍s,這個有效範圍不能大於相鄰兩個點的距離的一半。
* 手指落在這個有效範圍s內,則判定該touch對這個點有效,手勢路徑就會將這個點計算在內 手指落在點內則會有一個該點逐漸放大然後縮小的動畫效果
* 手指從一個點移出的時候在有效範圍s內
* ,手指到該點之間的線段是不顯示的,當手指移出範圍s,到手指離該點2s之間過程手指與該點之間的線段逐漸顯示,所以綜上來說s是小於點點間距的三分之一的
* 如果手勢密碼錯誤則已經連接的線和點都變成橘黃色,並持續三秒鐘再復原,如果在這三秒鐘內有手勢操作則立即復原
*
*
* @author monkey-d-wood
*/
public class GestureLockView extends View {
private static final int ANIM_DURATION = 150;// 點選中時候的動畫時間
private static final int RECOVER_DURATION = 3000;// 錯誤路徑持續時長
private int NORMAL_COLOR;// 正常的顏色
private int ERROR_COLOR;// 手勢錯誤顏色
private final int row = 3;// 矩陣的邊數
private PWDPath ppath;// 密碼路徑
private PPoint[][] points = new PPoint[3][3];
private boolean isRight = true;
public GestureLockView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
init(attrs);
}
public GestureLockView(Context context, AttributeSet attrs) {
super(context, attrs);
init(attrs);
}
public GestureLockView(Context context) {
super(context);
init(null);
}
private void init(AttributeSet attrs) {
// NORMAL_COLOR = Color.WHITE;
// ERROR_COLOR = Color.parseColor("#F54F20");
if (attrs != null) {
TypedArray ta = getContext().obtainStyledAttributes(attrs, R.styleable.lockview);
NORMAL_COLOR = ta.getColor(R.styleable.lockview_normalColor, Color.WHITE);
ERROR_COLOR = ta.getColor(R.styleable.lockview_wrongColor, Color.parseColor("#F54F20"));
ta.recycle();
}
ppath = new PWDPath();
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
drawPoints(canvas);
ppath.draw(canvas, isRight);
}
private void drawPoints(Canvas canvas) {
for (int i = 0; i < row; i++) {
for (int j = 0; j < row; j++) {
points[i][j].draw(canvas, true);
}
}
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {//該view爲矩形
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int w = getMeasuredWidth();
int h = getMeasuredHeight();
w = h = Math.min(w, h);
setMeasuredDimension(w, h);
}
private int width;//記錄view的邊長
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
width = w;
resetPoints(width);
}
/**
* 重置點的屬性
*/
private void resetPoints(int w) {
initSizeParams(w);
initPoints();
}
// 根據view的尺寸確定
private float pDis;// 兩點之間的間距 爲邊長的三分之一
private float pointR;// 點的初始半徑 爲pDis的二十分之一
private float pointREx;// 點的動畫最大半徑 爲點半徑的2.5倍
private float safeDis;// 點的‘領域’半徑 爲pDis的十分之三
private float fadeDis;// 路徑完全顯示的半徑 爲safeDis的二倍
private float mPadding;// view中點的矩形和view的外邊界距離 爲寬度的六分之一
private float lineW;// 畫出的線的寬度 爲點半徑的二分之一
/**
* 確定上邊提到的參數的值
*/
private void initSizeParams(int w) {
mPadding = w / 6.0f;
pDis = w / 3.0f;
pointR = pDis / 17;
pointREx = pointR * 2.5f;
safeDis = pDis * 3.0f / 10;
fadeDis = safeDis * 2.0f;
lineW = pointR / 1.8f;
ppath.setLineWidth(lineW);
}
private void initPoints() {
for (int i = 0; i < row; i++) {
for (int j = 0; j < row; j++) {
points[i][j] = new PPoint(i * pDis + mPadding, j * pDis + mPadding, pointR);
}
}
}
/**
* 顯示主體的點的實體類
*
* @author monkey-d-wood
*/
private class PPoint {
float x, y, r;//點橫縱座標和半徑
boolean animating;//是不是正在執行放大縮小動畫
boolean selected;//是不是已經被連入密碼path
Paint nPointpPaint;// 正常點畫筆
Paint ePointpPaint;// 錯誤的點得畫筆
public PPoint(float x, float y, float r) {
super();
this.x = x;
this.y = y;
this.r = r;
nPointpPaint = new Paint();
nPointpPaint.setColor(NORMAL_COLOR);
nPointpPaint.setStyle(Style.FILL_AND_STROKE);
ePointpPaint = new Paint();
ePointpPaint.setColor(ERROR_COLOR);
ePointpPaint.setStyle(Style.FILL_AND_STROKE);
}
public void draw(Canvas canvas, boolean a) {
canvas.drawCircle(x, y, r, a ? nPointpPaint : ePointpPaint);
}
// 判定是否應該連接該點
public boolean isInCtrl(float x, float y) {
return (x - this.x) * (x - this.x) + (this.y - y) * (this.y - y) <= safeDis * safeDis;
}
/**
* 設置點在二維數組中的座標
*/
public PPoint setIndex(int i, int j) {
this.i = i;
this.j = j;
return this;
}
int i, j;//記錄的二維數組中的座標
/**
* 點間距
*/
public float distanceTo(PPoint p2) {
return (float) Math.sqrt((this.x - p2.x) * (this.x - p2.x) + (this.y - p2.y) * (this.y - p2.y));
}
}
/**
* 的到觸點所在的‘領域’內的點, 如果不在領域內則返回null
*/
private PPoint getControllerPoint(float x, float y) {
for (int i = 0; i < row; i++) {
for (int j = 0; j < row; j++) {
if (points[i][j].isInCtrl(x, y)) {
return points[i][j].setIndex(i, j);
}
}
}
return null;
}
/**
* 復原view狀態
*/
private void reset() {
ppath = new PWDPath();
resetPoints(width);
isRight = true;
postInvalidate();
}
private Handler handler = new Handler();
private Runnable task = new Runnable() {
@Override
public void run() {
reset();
}
};
private PPoint down;
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
if (event.getAction() == MotionEvent.ACTION_DOWN) {
if (!isRight) {
handler.removeCallbacks(task);
reset();
}
}
return super.dispatchTouchEvent(event);
}
Result result = new Result();
@SuppressLint("ClickableViewAccessibility")
@Override
public boolean onTouchEvent(MotionEvent event) {
float eX = event.getX();
float eY = event.getY();
int action = event.getAction();
down = getControllerPoint(eX, eY);
if (null != down) {
if (!down.animating && !down.selected) {
doAnimation(down);
}
}
switch (action) {
case MotionEvent.ACTION_DOWN:
if (null != down && !down.selected) {
ppath.moveTo(down);
}
break;
case MotionEvent.ACTION_MOVE:
if (null != down && !down.selected) {
if (ppath.last == null) {
ppath.moveTo(down);
} else {
ppath.lineTo(down);
}
}
if (ppath.last != null) {
ppath.startTo(eX, eY);
}
invalidate();
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
ppath.pathStart.reset();
if (null != down && !down.selected) {
ppath.lineTo(down);
invalidate();
}
if (ppath.getPwd() != null && ppath.getPwd().length() > 0) {
if (null != callback) {
callback.onFinish(ppath.getPwd(), result);
}
if (result.isRight) {
reset();
} else {
isRight = false;
invalidate();
handler.postDelayed(task, RECOVER_DURATION);
}
}
break;
}
return true;
}
public class Result {
private boolean isRight;
public boolean isRight() {
return isRight;
}
public void setRight(boolean isRight) {
this.isRight = isRight;
}
}
/**
* 在這個點上做運動 o 動畫
*/
private void doAnimation(final PPoint down) {
ValueAnimator va = ValueAnimator.ofFloat(pointR, pointREx);
va.setDuration(ANIM_DURATION);
va.setRepeatCount(1);
va.setRepeatMode(ValueAnimator.REVERSE);
va.addUpdateListener(new AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
float value = Float.parseFloat(animation.getAnimatedValue().toString());
down.r = value;
invalidate();
}
});
va.addListener(new AnimatorListener() {
@Override
public void onAnimationStart(Animator animation) {
down.animating = true;
}
@Override
public void onAnimationRepeat(Animator animation) {
}
@Override
public void onAnimationEnd(Animator animation) {
down.animating = false;
down.selected = true;
}
@Override
public void onAnimationCancel(Animator animation) {
}
});
va.start();
}
/**
* 手勢密碼的抽象,包括路徑和路徑上的點 點集合就是路徑上已經‘吸附’的點,路徑有兩個 一個path 是點與點之間的連接的路徑
* 另外一個pathStart是
* 手指移動的位置和path最後一個‘吸附’的點之間的路徑,這個路徑不斷隨着手指移動而變化,並且還有透明度等需要處理所以單獨出來
*
* @author monkey-d-wood
*/
private class PWDPath {
// ArrayList<PPoint> pwdps;
Path path;// 正常的連接的路徑 即點與點之間的連接path
Path pathStart;// 從一個點出發 終點任意的path
PPoint last;
private Paint nPaint,// 正常顏色線的畫筆
ePaint, // 錯誤顏色線的畫筆
endPaint;// 手指移動的時候多餘部分的線的畫筆
LinkedHashSet<PPoint> pwdps;
public PWDPath() {
pwdps = new LinkedHashSet<GestureLockView.PPoint>();
path = new Path();
pathStart = new Path();
nPaint = new Paint();
nPaint.setColor(NORMAL_COLOR);
nPaint.setStyle(Style.STROKE);
endPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
endPaint.setColor(getColor(1.0f));
endPaint.setStyle(Style.STROKE);
endPaint.setStrokeCap(Cap.ROUND);
ePaint = new Paint();
ePaint.setColor(ERROR_COLOR);
ePaint.setStyle(Style.STROKE);
}
public String getPwd() {
StringBuilder sb = new StringBuilder();
for (PPoint p : pwdps) {
int k = p.i + 1 + p.j * row;
sb.append(k);
}
return sb.toString();
}
/**
* f範圍0~1
* 根據f值得到一個相應透明度的顏色
*/
private int getColor(float f) {
return Color.argb((int) (255 * f), Color.red(NORMAL_COLOR), Color.green(NORMAL_COLOR), Color.blue(NORMAL_COLOR));
}
public void setLineWidth(float lineW) {
nPaint.setStrokeWidth(lineW);
ePaint.setStrokeWidth(lineW);
endPaint.setStrokeWidth(lineW);
}
/**
* 移動到可以吸附的點,兩個路徑都需要接入這個點,兩個路徑都需要將這個點設爲起始點
*/
public void moveTo(PPoint p) {
path.reset();
path.moveTo(p.x, p.y);
pwdps = new LinkedHashSet<GestureLockView.PPoint>();
exAdd(p);
pwdps.add(p);
pathStart.reset();
pathStart.moveTo(p.x, p.y);
last = p;
}
/**
* 處理兩個連接點之間有一個未連接點得情況
*
* @param p
*/
private void exAdd(PPoint p) {
int i1 = null == last ? p.i : last.i;
int i2 = p.i;
int j1 = null == last ? p.j : last.j;
int j2 = p.j;
if (Math.abs(i1 - i2) == 2 && j1 == j2) {
if (!points[(i1 + i2) / 2][j1].selected) {
doAnimation(points[(i1 + i2) / 2][j1]);
pwdps.add(points[(i1 + i2) / 2][j1].setIndex((i1 + i2) / 2, j1));
}
} else if (Math.abs(j1 - j2) == 2 && i1 == i2) {
if (!points[i1][(j1 + j2) / 2].selected) {
doAnimation(points[i1][(j1 + j2) / 2]);
pwdps.add(points[i1][(j1 + j2) / 2].setIndex(i1, (j1 + j2) / 2));
}
} else if (Math.abs(i1 - i2) == 2 && Math.abs(j1 - j2) == 2) {
if (!points[(i1 + i2) / 2][(j1 + j2) / 2].selected) {
doAnimation(points[(i1 + i2) / 2][(j1 + j2) / 2]);
pwdps.add(points[(i1 + i2) / 2][(j1 + j2) / 2].setIndex((i1 + i2) / 2, (j1 + j2) / 2));
}
}
}
/**
* 意思同path的lineTo方法,path需要執行lineTo方法,而pathStart則仍然需要設置成起始點
*/
public void lineTo(PPoint p) {
path.lineTo(p.x, p.y);
exAdd(p);
pwdps.add(p);
pathStart.reset();
pathStart.moveTo(p.x, p.y);
last = p;
}
PPoint touchP;
/**
* 這是在吸附了一個點之後,手指移動
* 這個時候需要重新初始化該path,起始點爲pwdpath連接到的最後一個點,終點爲傳入的觸摸事件發生的位置
*/
public void startTo(float x, float y) {
pathStart.reset();
pathStart.moveTo(last.x, last.y);
pathStart.lineTo(x, y);
touchP = new PPoint(x, y, 0);
}
/**
* 繪製密碼路徑,正確的時候不畫點,錯誤的時候畫點
*/
public void draw(Canvas c, boolean isRight) {
if (isRight) {
c.drawPath(path, nPaint);
} else {
c.drawPath(path, ePaint);
for (PPoint point : pwdps) {
point.draw(c, isRight);
}
}
float factor = 1;
if (null != touchP) {//如果正在觸摸,根據觸摸點 到上一個連接的點的距離計算 多出線頭的透明度
float dis = touchP.distanceTo(last);
if (dis > fadeDis) {
factor = 1;
} else if (dis >= safeDis) {
factor = (dis - safeDis) * 1.0f / safeDis;
} else {
factor = 0;
}
}
endPaint.setColor(getColor(factor));
c.drawPath(pathStart, endPaint);
}
}
private GestureLockCallback callback;
public void setCallback(GestureLockCallback callback) {
this.callback = callback;
}
/**
* 手勢密碼回調接口
* @author monkey-d-wood
*/
public interface GestureLockCallback {
public void onFinish(String pwdString, Result result);
}
}
總結
代碼中用到的一些知識點:
- 在onMeasure()方法中確定控件的尺寸
- 屬性動畫中的ValueAnimator的用法
- 在回調接口中進行調用者和被調用者的雙向交流
- 漸變色原理
- 自定義view屬性等
深深感覺程序員在寫比較複雜的邏輯之前最好先用自己的語言描述一下,寫下來當然更好了,自然語言和程序設計語言還是有一些共通的地方的。當然也能幫助你避開代碼的語法複雜性而關注更宏觀的有效邏輯。複雜的邏輯也是有很多小的簡單的邏輯構成的,將這些邏輯拆分各個擊破,最終你就能完成原先你不敢想的複雜工作。
如果這篇博客激發了你的某些靈感或解決了你的某些困惑,那我深感榮幸。