1. 程式人生 > >Android中圖案鎖的實現

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<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;
        }

        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">

    <TextView
        android:id="@+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"/>

</com.liusiqian.patternlock.PatternLockLayout>
package com.liusiqian.patternlock;

import android.app.Activity;
import android.os.Bundle;
import android.widget.TextView;

public class MainActivity extends Activity implements PatternLockLayout.OnPatternStateListener
{
    private TextView tvInfo;
    private PatternLockLayout lockLayout;

    @Override
    protected void onCreate(Bundle savedInstanceState)
    {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        tvInfo = (TextView) findViewById(R.id.txt_patternlock_info);
        tvInfo.setText("請繪製圖案密碼");
        lockLayout = (PatternLockLayout) findViewById(R.id.layout_lock);
        lockLayout.setOnPatternStateListener(this);
    }


    @Override
    public void onFinish(String password, int sizeOfPoints)
    {
        if(sizeOfPoints<5)
        {
            tvInfo.setText("請連線至少5個點");
            lockLayout.setAllSelectedPointsError();
        }
        else if( !password.equals("1235789") )
        {
            tvInfo.setText("圖案密碼錯誤");
            lockLayout.setAllSelectedPointsError();
        }
        else
        {
            tvInfo.setText("圖案正確");
        }
    }

    @Override
    public void onReset()
    {
        tvInfo.setText("請繪製圖案密碼");
    }
}

  最後還是要重申一下,這個程式只是闡述了圖案鎖的核心功能,本身並不完美。很多寫死在程式裡的變數甚至是常量實際上都可以定義成屬性寫在xml檔案裡,然後在layout中配置,這樣能使得程式的可擴充套件性達到一個更高的層次,使用起來更加自如。
  原諒我犯懶,只做到這裡了~