前言

今天我们来手写一个关于手机九宫格解锁的一个 view ,我们将各种方法包括向外部传出密码的功能进行封装,写成我们自己的一个 view

效果展示

说明:此视图中的子控件全部都是 ImageView ,这里没有用到绘制

分析

我们首先来说明一下这个小demo的一些细节。

1.已经点亮的点不能重复点亮

2.已经点亮的点不能与其他任意一点之间形成连线

3.当手指完成九宫格解锁时,要向外部,如 Activity 传递 password ,由外部来判断密码是否正确

界面的布局

首先从界面的布局开始说起,九宫格顾名思义有九个点,其次就是6条横线,6条竖线,4条向右下方倾斜的线以及4条向左下方倾斜的线。接下来我们用代码的方式创建这些视图。

很显然我们需要一个容器来容纳下这些子控件,因此我们自己取名的视图 PicUnlockView 需要继承 ViewGruop ,并且它的三个构造方法我们都写出来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class PicUnlockView : ViewGroup {
/**
* 有三个构造方法,其中第三个构造方法
* 的三个参数分别是:
* 1.上下文
* 2.布局参数,它的类型是集合,里面放的是各种布局参数
* 3.布局样式
*/
constructor(context: Context) : super(context) {
//我们将所有视图的创建都放在initUi()里面
initUi()
}

constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
initUi()
}

constructor(context: Context, attrs: AttributeSet, style: Int) : super(context, attrs, style) {
initUi()
}

九个点的创建及显示

在创建9个点之前,我们需要先来计算一下必要的参数,由于我们待会会对创建好的点进行布局,因此会重复用到两个点之间的距离,所以我们先将它计算出来并声明成全局变量。

1
2
3
4
5
6
//已测量父容器的宽高
//由于父容器的宽高在这个时候才会被测量出来,所以我们space在这里计算
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
//计算两个点之间的间距
space = (width - 3 * dotSize) / 2
}

我们明白在手指拖动或点击的过程中需要将符合条件的点和线条显示出来,那我们如何能做到呢?怎么才能让代码明白我们的意图呢?

这里我们就可以引入 tag 值,将每个点的 tag 值以此设置成 1..9 ,接着就将两点之间的线段用这两个点的 tag 拼接起来,成为这条线段的 tag 值,比如第一条横线就是 12 ,然后将这些 tag 存在一个集合中。

这样当我们手指在屏幕上滑动的时候,就可以算出手指经过两点的 tag 值,进而推出两点间的线段的 tag 值,最后判断这个 tag 值是否存在,若存在则显示 tag 相对应的线段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
//这个方法用来创建9个点
private fun initNineDot() {
for (i in 1..9) {
ImageView(context).apply {
//给每张图片设置最初显示的图片
setImageResource(R.drawable.dot_normal)
//设置每个点的 tag 值
tag = "$i"
//将每个 ImageView 加到容器中去
addView(this)
//收集已经创建好的点
dotViews.add(this)
}
}
}

//这个方法用来指定这9个点如何布局和显示
private fun layoutNineDot() {
for (row in 0..2) {
for (column in 0..2) {
//指定每个图片的 左 上 右 下 各个位置
val left = column * (dotSize + space)
val top = row * (dotSize + space)
val right = left + dotSize
val bottom = top + dotSize
/**
* 每个视图添加到容器之后,后一个视图会重叠在前一个视图上
* 第一个被添加上去的索引是0,第二个就是1...
* 因此可以用索引来拿到每个视图,并将其摆放在合适的位置
* 用 getChildAt(index) 来得到视图
*/
val index = row * 3 + column
val dotView = getChildAt(index)
//将这个子控件进行布局,告诉它因该在父容器中如何显示
dotView.layout(left, top, right, bottom)
}
}
}

六条横线的创建及显示

由于六条横线和上面九个点的创建和显示大同小异,这里就省略了一些注释,其实道理都是一样的,计算 tag 值,计算对应 viewindex 值,计算 left top right bottom 并进行对应的布局,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
private fun initHorizontalLine() {
/**
* 这是六条横线对应的 tag 值
* 12 23
* 45 56
* 78 89
*/
var tag = 0
for (i in 0..5) {
ImageView(context).apply {
setImageResource(R.drawable.line_horizontal)
if (i == 0) {
tag = 12
} else {
tag += i % 2 * 11 + (i + 1) % 2 * 22
}
this.tag = tag
Log.d(TAG, "tag: $tag")
Log.d(TAG, "initLandscapeLine: ")
addView(this)
}
}
}

private fun layoutHorizontalLine() {
for (row in 0..2) {
for (column in 0..1) {
val left = dotSize + column * (dotSize + space)
val top = dotSize / 2 + row * lineSize
val right = left + space
val bottom = top + dp2px(2)

//找到这根线在父容器中的索引
val index = 9 + row * 2 + column
val lineView = getChildAt(index)
lineView.layout(left, top, right, bottom)
lineView.visibility = INVISIBLE
}
}
}

六条竖线的创建与显示

这六条竖线和上面的水平线创建没有什么差异,因此下面直接给出代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
//创建六条竖线
private fun initVerticalLine() {
//14 25 36
//47 58 69
var tag = 0
for (i in 0..5) {
ImageView(context).apply {
setImageResource(R.drawable.line_vertical)
if (i == 0) {
tag = 14
} else {
tag += 11
}

this.tag = tag
addView(this)
}
}
}


//对这六条竖线进行布局
private fun layoutVerticalLine() {
for (row in 0..1) {
for (column in 0..2) {
val left = dotSize / 2 + column * (dotSize + space)
val top = dotSize + row * (dotSize + space)
val right = left + dp2px(2)
val bottom = top + space

//找到这根线在父容器中的索引
val index = 15 + row * 3 + column
val lineView = getChildAt(index)
lineView.layout(left, top, right, bottom)
lineView.visibility = INVISIBLE
}
}
}

4条向右倾斜的直线

上同,只不过这里需要注意的是由于几条线都是对角线,而我们的点都是圆点,因此考虑线条left top 值的时候需要引入根号,这里虽然计算不复杂,但是通过代码写出来还是有点麻烦的,需要我们仔细敲敲。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
//创建四条向右倾斜的直线
private fun initSlashLine() {
//15 26
//48 59
var tag = 0
for (i in 0..3) {
ImageView(context).apply {
setImageResource(R.drawable.line_right)
if (i == 0) {
tag = 15
} else {
tag += (i % 2) * 11 + (i + 1) % 2 * 22
}
this.tag = tag
Log.d(TAG, "initSlashLine: tag = $tag")
addView(this)
}
}
}


//对这四条直线进行布局
private fun layoutSlashLine() {
for (row in 0..1) {
for (column in 0..1) {
val left = dotSize / 2 + sqrt(2.0) / 4 * dotSize + column * (dotSize + space)
val top = dotSize / 2 + sqrt(2.0) * dotSize / 4 + row * (dotSize + space)
val right = left + space + (dotSize / 2 - sqrt(2.0) * dotSize.toDouble() / 4) * 2
val bottom = top + space + (dotSize / 2 - sqrt(2.0) * dotSize.toDouble() / 4) * 2

//找到这根线在父容器中的索引
val index = 21 + row * 2 + column
val lineView = getChildAt(index)
lineView.layout(left.toInt(), top.toInt(), right.toInt(), bottom.toInt())
lineView.visibility = INVISIBLE
}
}
}

4条向左倾斜的直线

上同,这里直接给出代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
private fun initLLine() {
//24 35
//57 68
var tag = 0
for (i in 0..3) {
ImageView(context).apply {
setImageResource(R.drawable.line_left)
if (i == 0) {
tag = 24
} else {
tag += (i % 2) * 11 + (i + 1) % 2 * 22
}
this.tag = tag
addView(this)
}
}
}

private fun layoutLLine() {
for (row in 0..1) {
for (column in 0..1) {
val left = dotSize / 2 + sqrt(2.0) / 4 * dotSize + column * (dotSize + space)
val top = dotSize / 2 + sqrt(2.0) * dotSize / 4 + row * (dotSize + space)
val right = left + space + (dotSize / 2 - sqrt(2.0) * dotSize.toDouble() / 4) * 2
val bottom = top + space + (dotSize / 2 - sqrt(2.0) * dotSize.toDouble() / 4) * 2

//找到这根线在父容器中的索引
val index = 25 + row * 2 + column
val lineView = getChildAt(index)
lineView.layout(left.toInt(), top.toInt(), right.toInt(), bottom.toInt())
lineView.visibility = INVISIBLE
}
}
}

界面的逻辑

现在我们来思考一下这个界面的逻辑,当我们手指点击或者滑动或者抬起时,这个 view 应该作出怎样的回应,应该显示什么样的内容,最后还有怎么将密码传递给外部去。

解锁图案的实现

我们单独用一个方法 dealWithTouchPoint(),处理手指按下或滑动后视图相应的逻辑。通过给每一个小点加上 矩形区域(Rect),然后使用里面的方法 contains() 来判断当前触摸位置是否在这个小点上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
private fun dealWithTouchPoint(x: Float, y: Float) {
dotViews.forEach {
//给每个点加上一个矩形区域
val rect = RectF(
it.x,
it.y,
it.x + it.width,
it.y + it.height
)
if (rect.contains(x, y)) {
//判断是不是第一个点
if (lastSelectedDotView == null) {
//直接点亮
changeSelectedDotViewStatus(it, ViewStatus.SELECTED)
} else {
val lastTag = (lastSelectedDotView!!.tag as String).toInt()
val currentTag = (it.tag as String).toInt()
//获取两点间线的tag
val lineTag = if (lastTag < currentTag)
lastTag * 10 + currentTag
else
currentTag * 10 + lastTag
//判断tags数组中是否有这个值
if (allLineTags.contains(lineTag)) {
//存在这条线,使用tag将它拿到
val lineView = findViewWithTag<ImageView>(lineTag)
if (!allSelectedDotViews.contains(it)) {
//点亮点
changeSelectedDotViewStatus(it, ViewStatus.SELECTED)
if (!allSelectedLineViews.contains(lineView)) {
//点亮线
changeSelectedLineStatus(lineView, ViewStatus.SELECTED)
}
}
}
}
}
}
}

向外部传递密码

我们使用高阶函数(callback)来实现外部传值的功能。通过外部来接收这个密码,再作相应的逻辑,比如判断密码是否正确。

1
2
3
4
5
6
var callback: ((String) -> Unit)? = null
private fun dealWithResult() {
callback?.let {
it(passwordBuilder.toString())
}
}

总结

当前这个 view 复用性较高,完成了一套密码视图该有的功能,但不足的是线条没有通过绘制完成,而是依靠图片。

这里提供的仅为部分核心代码,完整代码请见:https://github.com/cofbro/android-unlockView