先上一张效果图:

Android 仿系统桌面九宫格 安卓9宫格解锁_控件

实现思路:

1.绘制点层
2.绘制线层
3.处理触摸事件(相对来说的难点)
4.实现绘制结束时的回调

写在前面:
实现过程中我们先不做大小适配,将控件的大小写死,根据原型图,将控件大小设置为边长为790px的正方形,其中的圆点大小,涉及到的线的宽度等一系列长度信息均使用测量得到的长度(px)表示,最后再以百分比的形式做屏幕大小适配,比如,我的原型图宽度是790px,其中圆点直径大小为86px,那么如果我们在手机上要将该控件显示为300dp*300dp大小,也只要直接设置 layout_width=”300dp” 即可,最终会将控件的宽高显示为300dp,而其中的圆点直径为 300dp*(86/790) 通过百分比适配是为了始终保持控件的各部分大小比例,不会因为控件大小的改变而使其变形,该控件已做了百分比适配工作,直接设置想要的大小即可,如果设置为wrap_content,则显示默认的790px。

Android 仿系统桌面九宫格 安卓9宫格解锁_Android 仿系统桌面九宫格_02

Android 仿系统桌面九宫格 安卓9宫格解锁_android_03

onDraw()中的绘制相关代码不做过多解释,就是一些绘制圆形和线的方法,唯一需要注意的就是一个转移画布原点的思路,绘制哪一个点的时候,直接将画布中心点转移到该点的中心,这样绘制起这九个圆形的时候特别方便。

最关键点便是触摸点的控制,我们将九个点分别标识为9个对应区域号0,1,2,……,8,然后通过获得手指的触摸位置判断某个点是否被按住了,如果被按住了,将该点的编号加入到存储触摸点的List,最后当手指松开的时候,得到的List就是我们的绘制密码,以下是关键点额onTouchEvent()和计算手指点击区域的方法。

@Override
    public boolean onTouchEvent(MotionEvent event) {
//        return super.onTouchEvent(event);
        if (event.getAction() == MotionEvent.ACTION_DOWN && list.size() > 0) {
            list.clear();
            isRightPath = true;
            isListEmpty = true;
            fingerPath.clear();
            isFingerUp = false;
        }

        int area = calculateArea(event.getX(), event.getY());
        if (area != -1) {   //如果当前手指按在了某个区域,则刷新控件开始画线
            if (!list.contains(area)) { //点的状态只刷新一次
                Log.i("event", "x:" + event.getX() + " " + "y:" + event.getY() + " " + "area:" + area);
                invalidateStatus(area);
            }
        }
        //移动过程中线的状态不断刷新
        touchX = event.getX();
        touchY = event.getY();
        lineView.invalidate();

        boolean isRight = true;

        if (event.getAction() == MotionEvent.ACTION_UP) {
            ArrayList<Integer> gesturePathList = getGesturePath();//手指抬起时获取绘制的路径
            if (onDrawLockPathListener != null) {
                isRight = onDrawLockPathListener.onDrawPathFinish(gesturePathList);
            }
            if (isRight) {
                showPathAfterFingerUp(1000, true);
            } else {
                showPathAfterFingerUp(1000, false);
            }
        }
        return true;
    }

    /**
     * 通过传入的手指相对控件左上角坐标,计算当前手指所在区域
     *
     * @param x x
     * @param y y
     * @return 区域标识
     */
    private int calculateArea(float x, float y) {
        int area = -1;
        if (x > 45 * ratioScale && x < 212 * ratioScale && y > 45 * ratioScale && y < 212 * ratioScale) {   //进入了第一个点的区域
            area = 0;
        } else if (x > 308 * ratioScale && x < 474 * ratioScale && y > 45 * ratioScale && y < 212 * ratioScale) {
            area = 1;
        } else if (x > 566 * ratioScale && x < 738 * ratioScale && y > 45 * ratioScale && y < 212 * ratioScale) {
            area = 2;
        } else if (x > 45 * ratioScale && x < 212 * ratioScale && y > 308 * ratioScale && y < 474 * ratioScale) {
            area = 3;
        } else if (x > 308 * ratioScale && x < 474 * ratioScale && y > 308 * ratioScale && y < 474 * ratioScale) {
            area = 4;
        } else if (x > 566 * ratioScale && x < 738 * ratioScale && y > 308 * ratioScale && y < 474 * ratioScale) {
            area = 5;
        } else if (x > 45 * ratioScale && x < 212 * ratioScale && y > 566 * ratioScale && y < 738 * ratioScale) {
            area = 6;
        } else if (x > 308 * ratioScale && x < 474 * ratioScale && y > 566 * ratioScale && y < 738 * ratioScale) {
            area = 7;
        } else if (x > 566 * ratioScale && x < 738 * ratioScale && y > 566 * ratioScale && y < 738 * ratioScale) {
            area = 8;
        }
        return area;
    }

还有一个要注意的地方是最后一个点与手指的连线,在初始化存储区域List的时候,直接加入一个点作为手指点击区域,以后每次手指按住一个点击区域的时候,直接加在这个点的前面,以方便最后一个点同手指之间的连线处理。

/**
     * 根据手指区域刷新界面
     *
     * @param area 区域
     */
    private void invalidateStatus(int area) {
        if (isListEmpty) {
            list.add(area);
            list.add(100);    //区域点始终多一个,表示手指所在区域,用来连线
            isListEmpty = false;
        } else {
            list.add(list.size() - 1, area);  //将新加入的手指按压点加在当前手指所在区域之前
        }
        pointView.invalidate();
    }

以下是该控件的完整代码,写得比较仓促,没有对太多地方做出详细解释,改进和优化的地方肯定还有不少,如有疑问和建议请留言。

GitHub地址:https://github.com/shixiuwen/NineGridLock

同时并没有对外暴露出可直接设置属性的方法,因为本人认为没有什么更改难点,要更改也只有颜色和样式之类的,如果真有使用需求并有修改需求的话直接在代码中修改比引用库文件通过方法设置会更有安全感,毕竟这就一个类……

完整代码:

NinePointLockView.class
package com.shixia.ninegridlock.view;

import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.RelativeLayout;

import java.util.ArrayList;
import java.util.List;
import java.util.Timer;
import java.util.TimerTask;

/**
 * Created by AmosShi on 2016/11/11.
 * <p>
 * Description: 自定义控件,九宫格应用解锁,已做好屏幕适配,直接在布局文件中写想要的大小即可
 * 监听:setOnDrawLickPathListener(OnDrawLockPathListener onDrawLickPathListener)
 * 回调接口: OnDrawLockPathListener
 * <p>
 * Email:shixiuwen1991@yeah.net
 * <p>
 * 分析:需要绘画层级:
 * 1.点层
 * 2.线层
 */

public class NinePointLockView extends FrameLayout {

    private Paint pointPaint;   //用于画点的paint
    private Paint linePaint;    //用户画线的paint

    private PointView pointView;
    private LineView lineView;

    private ArrayList<Integer> list;    //用于存储绘制的路径 {横向:0,1,2,3,4,5,6,7,8}
    private ArrayList<Integer> fingerPath = new ArrayList<>();  //临时回调的时候使用

    private boolean isListEmpty = true; //表示该区域是否已经连接上

    private float touchX, touchY;

    private int resultRule = 790; //onMeasure比例尺,用作绘制比例,790是初始值,最终会根据实际大小重新赋值

    private float ratioScale = 0;   //在原比例尺上的缩放比例

    private int measureWidth, measureHeight;

    private Float[][] localPoint;   //绘制的连接线坐标点位置

    private boolean isRightPath = true;
    private static boolean isFingerUp = true;   //之后手指抬起的时候才主动使用timer清除路径

    public NinePointLockView(Context context) {
//        super(context);
        this(context, null);
    }

    public NinePointLockView(Context context, AttributeSet attrs) {
        super(context, attrs);
        list = new ArrayList<>();
//        initScare();
        pointView = new PointView(context, attrs);
        lineView = new LineView(context, attrs);
        RelativeLayout.LayoutParams layoutParams = new RelativeLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
        addView(pointView, layoutParams);
        addView(lineView, layoutParams);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
//        return super.onTouchEvent(event);
        if (event.getAction() == MotionEvent.ACTION_DOWN && list.size() > 0) {
            list.clear();
            isRightPath = true;
            isListEmpty = true;
            fingerPath.clear();
            isFingerUp = false;
        }

        int area = calculateArea(event.getX(), event.getY());
        if (area != -1) {   //如果当前手指按在了某个区域,则刷新控件开始画线
            if (!list.contains(area)) { //点的状态只刷新一次
                Log.i("event", "x:" + event.getX() + " " + "y:" + event.getY() + " " + "area:" + area);
                invalidateStatus(area);
            }
        }
        //移动过程中线的状态不断刷新
        touchX = event.getX();
        touchY = event.getY();
        lineView.invalidate();

        boolean isRight = true;

        if (event.getAction() == MotionEvent.ACTION_UP) {
            ArrayList<Integer> gesturePathList = getGesturePath();//手指抬起时获取绘制的路径
            if (onDrawLockPathListener != null) {
                isRight = onDrawLockPathListener.onDrawPathFinish(gesturePathList);
            }
            if (isRight) {
                showPathAfterFingerUp(1000, true);
            } else {
                showPathAfterFingerUp(1000, false);
            }
        }
        return true;
    }

    /**
     * 手指抬起后将路径显示一段指定的时长,如果路径不正确(表示不匹配),则将路径显示为红色错误状态
     *
     * @param duration 路径显示时长,ms
     * @param isRight  路径是否错误,如果路径错误,则显示为红色
     */
    private void showPathAfterFingerUp(long duration, boolean isRight) {
        isRightPath = isRight;
        pointView.invalidate();
        lineView.invalidate();
        Timer timer = new Timer();
        TimerTask timerTask = new TimerTask() {
            @Override
            public void run() {
                if (isFingerUp) {
                    isRightPath = true;
                    isListEmpty = true;
                    list.clear();
                    pointView.postInvalidate();
                    lineView.postInvalidate();
                }
                isFingerUp = true;
            }
        };
        timer.schedule(timerTask, duration);
    }

    /**
     * 计算当前手指所在区域
     *
     * @param x x
     * @param y y
     * @return 区域标识
     */
    private int calculateArea(float x, float y) {
        int area = -1;
        if (x > 45 * ratioScale && x < 212 * ratioScale && y > 45 * ratioScale && y < 212 * ratioScale) {   //进入了第一个点的区域
            area = 0;
        } else if (x > 308 * ratioScale && x < 474 * ratioScale && y > 45 * ratioScale && y < 212 * ratioScale) {
            area = 1;
        } else if (x > 566 * ratioScale && x < 738 * ratioScale && y > 45 * ratioScale && y < 212 * ratioScale) {
            area = 2;
        } else if (x > 45 * ratioScale && x < 212 * ratioScale && y > 308 * ratioScale && y < 474 * ratioScale) {
            area = 3;
        } else if (x > 308 * ratioScale && x < 474 * ratioScale && y > 308 * ratioScale && y < 474 * ratioScale) {
            area = 4;
        } else if (x > 566 * ratioScale && x < 738 * ratioScale && y > 308 * ratioScale && y < 474 * ratioScale) {
            area = 5;
        } else if (x > 45 * ratioScale && x < 212 * ratioScale && y > 566 * ratioScale && y < 738 * ratioScale) {
            area = 6;
        } else if (x > 308 * ratioScale && x < 474 * ratioScale && y > 566 * ratioScale && y < 738 * ratioScale) {
            area = 7;
        } else if (x > 566 * ratioScale && x < 738 * ratioScale && y > 566 * ratioScale && y < 738 * ratioScale) {
            area = 8;
        }
        return area;
    }

    /**
     * 根据手指区域刷新界面
     *
     * @param area 区域
     */
    private void invalidateStatus(int area) {
        if (isListEmpty) {
            list.add(area);
            list.add(100);    //区域点始终多一个,表示手指所在区域,用来连线
            isListEmpty = false;
        } else {
            list.add(list.size() - 1, area);
        }
        pointView.invalidate();
    }

    /**
     * 九宫格中的点视图
     */
    private class PointView extends View {

        public PointView(Context context) {
//            super(context);
            this(context, null);
        }

        public PointView(Context context, AttributeSet attrs) {
            super(context, attrs);
            initPointPaint();
            post(new Runnable() {
                @Override
                public void run() {
                    localPoint = new Float[][]{{130 * ratioScale, 130 * ratioScale}, {395 * ratioScale, 130 * ratioScale}, {660 * ratioScale, 130 * ratioScale},
                            {130 * ratioScale, 395 * ratioScale}, {395 * ratioScale, 395 * ratioScale}, {660 * ratioScale, 395 * ratioScale},
                            {130 * ratioScale, 660 * ratioScale}, {395 * ratioScale, 660 * ratioScale}, {660 * ratioScale, 660 * ratioScale}};
                }
            });
        }

        /**
         * 初始化画点的画笔
         */
        private void initPointPaint() {
            pointPaint = new Paint();
            pointPaint.setARGB(255, 166, 166, 166);
            pointPaint.setAntiAlias(true);
            pointPaint.setDither(true);
        }

        @Override
        protected void onDraw(Canvas canvas) {
            super.onDraw(canvas);

            //将画布移到布局空间的中间,防止布局为长方形
            canvas.translate(Math.abs(resultRule - measureWidth) / 2, Math.abs(resultRule - measureHeight) / 2);

            //转移画布中心点到中心
            canvas.translate(395 * ratioScale, 395 * ratioScale);

            //画中间的那个点
            drawPoint(0, 0, canvas, 4);
            //画左上角的点
            drawPoint((int) (-264 * ratioScale), (int) (-264 * ratioScale), canvas, 0);
            //画右上角的点
            drawPoint((int) (264 * ratioScale), (int) (-264 * ratioScale), canvas, 2);
            //画左下角的点
            drawPoint((int) (-264 * ratioScale), (int) (264 * ratioScale), canvas, 6);
            //画右下角的点
            drawPoint((int) (264 * ratioScale), (int) (264 * ratioScale), canvas, 8);
            //画上边的点
            drawPoint(0, (int) (-264 * ratioScale), canvas, 1);
            //画下边的点
            drawPoint(0, (int) (264 * ratioScale), canvas, 7);
            //画左边的点
            drawPoint((int) (-264 * ratioScale), 0, canvas, 3);
            //画右边的点
            drawPoint((int) (264 * ratioScale), 0, canvas, 5);

        }

        @Override
        protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
//            super.onMeasure(widthMeasureSpec, heightMeasureSpec);
            measureWidth = (int) getMeasureSize(widthMeasureSpec, true);
            measureHeight = (int) getMeasureSize(heightMeasureSpec, false);
            setMeasuredDimension(measureWidth, measureHeight);
            resultRule = Math.min(measureWidth, measureHeight); //防止布局为长方形,返回较小的变用作参考系
            ratioScale = resultRule / 790f;
        }

        private void drawPoint(int translateX, int translateY, Canvas canvas, int area) {
            canvas.save();
            canvas.translate(translateX, translateY);
            //已选中的点绘制为其他颜色(红色)
            if (list.contains(area)) {
                if (!isRightPath) {
                    pointPaint.setColor(Color.argb(255, 255, 0, 0));
                } else {
                    pointPaint.setColor(Color.argb(255, 75, 217, 191));
                }
//                pointPaint.setColor(Color.RED);
                pointPaint.setStyle(Paint.Style.FILL);
                canvas.drawCircle(0, 0, 27 * ratioScale, pointPaint);
            }

            pointPaint.setStyle(Paint.Style.STROKE);
            pointPaint.setStrokeWidth(6 * ratioScale);
            canvas.drawCircle(0, 0, 86 * ratioScale, pointPaint);
            //恢复画笔颜色
            pointPaint.setARGB(255, 166, 166, 166);
            canvas.restore();
        }
    }

    /**
     * 九宫格中的线视图
     */
    private class LineView extends View {

        public LineView(Context context) {
//            super(context);
            this(context, null);
        }

        public LineView(Context context, AttributeSet attrs) {
            super(context, attrs);
            initLinePaint();
        }

        //初始化画线的笔
        private void initLinePaint() {
            linePaint = new Paint();
//        linePaint.setARGB(255, 101, 188, 80);
            linePaint.setARGB(255, 75, 217, 191);
            linePaint.setStrokeCap(Paint.Cap.ROUND);
            linePaint.setAntiAlias(true);
            linePaint.setDither(true);
        }

        @Override
        protected void onDraw(Canvas canvas) {
            super.onDraw(canvas);

            //将画布移到布局空间的中间
            canvas.translate(Math.abs(resultRule - measureWidth) / 2, Math.abs(resultRule - measureHeight) / 2);

            linePaint.setStrokeWidth(8 * ratioScale);    //设置笔触宽度,不能在onDraw之前设置,否则resultRule为初始化值(可以在post中初始化)

            if (!isRightPath) {
                linePaint.setColor(Color.argb(255, 255, 0, 0));
            }

            //从list中获取所需要连接的区域,最后一个区域和手指所在点相连接
            int size = list.size();
            //每一个area都对应一个坐标点
            if (size >= 3) {    //表示至少连接了两个点
                for (int i = 0; i < size - 2; i++) {
                    Float[] from = localPoint[list.get(i)];   //拿到list中第i个区域对应的坐标
                    Float[] to = localPoint[list.get(i + 1)];
                    canvas.drawLine(from[0], from[1], to[0], to[1], linePaint);
                }
            }
            if (size >= 2) {
                if (!isRightPath) {
                    return;
                }
                Float[] listPoint = localPoint[list.get(list.size() - 2)];    //拿到最后一个点连接坐标,从改点画到手指触点的连接
                canvas.drawLine(listPoint[0], listPoint[1], touchX, touchY, linePaint);
            }
            //恢复画笔默认颜色
            linePaint.setColor(Color.argb(255, 75, 217, 191));
        }
    }

    /**
     * 获取录入的手势密码
     *
     * @return 手势密码对应区域的 list
     */
    private ArrayList<Integer> getGesturePath() {
        if (list.size() <= 4) {
            return null;    //必须连接4个点密码才生效
        }
        fingerPath.clear();
        fingerPath.addAll(list);
        fingerPath.remove(fingerPath.size() - 1);     //去除最后一个滑动中的手指按压点
        return fingerPath;
    }

    private float getMeasureSize(int length, boolean isWidth) {
        int size = MeasureSpec.getSize(length); //获取尺寸
        int mode = MeasureSpec.getMode(length); //获取模式
        float resSize = 0;    //最终计算所得大小,被返回
        if (mode == MeasureSpec.EXACTLY) {    //具体指定大小,比如30dp或者match_parent(fill_parent)
            resSize = size;
        } else { //未具体指定大小
            if (mode == MeasureSpec.AT_MOST) {    //wrap_content
                if (isWidth) {      //因为为正方形,该控件中可以不进行判断
                    resSize = 790;
                } else {
                    resSize = 790;
                }
            }
        }
        return resSize;
    }


    /**
     * ################################# 绘制结束的回调接口 #####################################
     */

    private OnDrawLockPathListener onDrawLockPathListener;

    //设置绘制结束的监听
    public void setOnDrawLickPathListener(OnDrawLockPathListener onDrawLickPathListener) {
        this.onDrawLockPathListener = onDrawLickPathListener;
    }

    public interface OnDrawLockPathListener {
        /**
         * 绘制路径结束后的回调函数,传入的参数可能为空,表示连接的点数小于4个
         *
         * @param pathList 路径值List :{ 横向:
         *                 0,1,2,
         *                 3,4,5,
         *                 6,7,8
         *                 }
         * @return 绘制的路径是否是正确路径,正确与否同本地记录的路径匹配,返回true将显示
         * 结果为绿色路径,表示匹配,返回false将显示红色路径,表示路径同本地的不匹配,如果是
         * 新录入路径,直接返回true即可
         */
        boolean onDrawPathFinish(List<Integer> pathList);
    }
}
MainActivity.class
package com.shixia.ninegridlock;

import android.os.Bundle;
import android.support.v7.app.AppCompatActivity;
import android.text.TextUtils;
import android.widget.Toast;

import com.shixia.ninegridlock.view.NinePointLockView;

import java.util.List;

public class MainActivity extends AppCompatActivity {

    private NinePointLockView nplvLockView;
    private String password = "";

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        nplvLockView = (NinePointLockView) findViewById(R.id.nplv_lock_view);

        nplvLockView.setOnDrawLickPathListener(new NinePointLockView.OnDrawLockPathListener() {
            @Override
            public boolean onDrawPathFinish(List<Integer> pathList) {
                if (pathList != null) {
                    StringBuilder builder = new StringBuilder();
                    for (Integer integer : pathList) {
                        builder.append(integer);
                    }
                    //同上一次密码比对
                    if (TextUtils.isEmpty(password)) {      //本地未存储密码,本次操作为新录入密码
                        Toast.makeText(MainActivity.this, "连接的点序列为:" + builder.toString(), Toast.LENGTH_SHORT).show();
                        password = builder.toString();
                        return true;
                    } else {        //本地存储了密码,进行匹配
                        if (!TextUtils.equals(password, builder.toString())) {  //密码匹配失败,显示错误路径,本地密码置空
                            Toast.makeText(MainActivity.this, "密码错误,已清空密码,请重置,当前路径:" + builder.toString(), Toast.LENGTH_SHORT).show();
                            password = null;
                            return false;
                        } else {    //密码匹配成功
                            Toast.makeText(MainActivity.this, "密码正确" + builder.toString(), Toast.LENGTH_SHORT).show();
                            return true;
                        }
                    }
                } else {
                    Toast.makeText(MainActivity.this, "请至少连接4个点", Toast.LENGTH_SHORT).show();
                    return false;
                }
            }
        });

    }
}
layout_main.xml
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/activity_main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="com.shixia.ninegridlock.MainActivity">

    <com.shixia.ninegridlock.view.NinePointLockView
        android:layout_width="300dp"
        android:layout_height="300dp"
        android:layout_centerInParent="true"
        android:id="@+id/nplv_lock_view"/>

</RelativeLayout>