文章也同时在个人博客 http://kimihe.com/更新
1. 引言
前几天支付宝刚刚更新了双十一logo,各电商备战的氛围越来越浓厚。支付宝每天都用得相当频繁,为提高账户安全性,我开启了“手势密码”功能,首次进入敏感页面会要求验证九宫格手势来解锁。
这个九宫格和安卓上面的绘图解锁非常类似,这引起了我自制一个的想法。而且最近一直在看微机汇编,有段时间没有搞iOS了,为避免生疏就拿它练手吧。
2. Demo地址
KMNineBoxDemo
Demo中仍有不少可以进一步改进的地方,欢迎各位读者提出宝贵意见。
3. 效果预览
这里的图案是绘制数字2的形状,对应的手势序列就是123654789。
可以看到九宫格手势较好地检测了触摸点的位置,跟踪了手势,并在验证后反馈不同的显示效果。绘制的手势图案除了简单的一步到底的图案,你也可以绘制一些连线交叉的复杂图案。
4. 几点说明
- 虽说是仿支付宝的手势密码,但由于水平有限,难以100%仿制。大家可以看到图案的颜色和大小有些许不同,这里也是尽量模仿。
- 支付宝的手势密码在绘制图案时,会有箭头指向以及实时更新的连线,连线会跟随用户的手指不断移动,这里的箭头我没有增加进去,但应该是较容易实现的,主要是没有箭头的切图。而实时跟踪的连线,我暂时还没有想到特别好的融合方法,虽说也可以加入,但总觉得以目前水平写出来的实时连线,效率不高,故没有加入。
- 至于上方的两个label,都可以通过KMNineBoxView的接口中拿到验证和手势序列信息,设置起来非常简单。
5. 如何使用该控件
参考ViewController.m
中的示例,结合模拟器运行,大家可以快读地理解该控件的使用场景。
在KMNineBoxView.h
中说明了几个接口的使用说明。这里需要注意的一点是,为了能够较好地根据验证结果返回显示,在使用前需要设置好正确的密码序列,然后在用户绘制完手势图案后,会进行比较,根据结果显示不同的颜色。
- (void)nineBoxDidFinishWithState:(KMNineBoxState)state passSequence:(NSString *)passSequence;
这个接口中也会返回验证结果和用户绘制的序列,KMNineBoxView本身已经会自动根据验证结果设置不同的显示,这里还提供这个接口主要是给用户更大的自由度,方便用户进行更多的个性化设置。例如用户可以根据拿到的用户序列,提示用户一些信息,正如Demo里面的使用情景。
6. Demo结构
Demo主要由KMUIKitMacro.h
头文件,KMMathHelper
类,KMNineBoxView
类,以及ViewController
类构成。
-
KMUIKitMacro.h
头文件是一些预定义的宏,用于简化代码。 -
KMMathHelper
类提供了一些数学计算工具。 -
KMNineBoxView
类就是我们实现的九宫格手势密码,后文会着重讲解它。 -
ViewController
类结合storyboard使用我们的自定义控件。
7. 原理讲解
7.1 难点
首先罗列一些实现过程中我认为的难点,其实与其说是难点,不如说是需要注意或者是可以优化的地方。
- Layer的布局,显示正确的图案。
- 用户手势的状态,以此对应设置KMNineBoxView状态。
- 用户触摸点的位置判断,即落在9格圆圈的哪一个里面。
- 跟踪用户触摸过的轨迹,即记录下9个圆圈哪些被触摸过,以及它们的顺序。
- 圆圈间的连接线。
- 给予手势反馈结果,即验证通过还是手势错误。
下面的讲解着重说明思路和注意点,代码会贴上一些关键部分,完整的代码请见工程。
7.2 Layer布局
常见地重写- (instancetype)initWithFrame:(CGRect)frame
方法,提供初始化方法,并在其中计算一些布局尺寸和画出9个基本的圆圈,如下:
- (instancetype)initWithFrame:(CGRect)frame
{
self = [super initWithFrame:frame];
if (self) {
[self initData];
[self setupBoundsAndFrame];
[self drawNineBox];
}
return self;
}
考虑到用户可能会跳针view的frame,因此需要启用- (void)layoutSubviews
方法,在其中重新计算布局,并调整圆圈的frame,如下:
- (void)layoutSubviews
{
//改变frame,subviews,sublayers会跑进来
[self setupBoundsAndFrame];
[self reloadNineBox];
}
这里需要注意layoutSubviews这个方法的使用,如果不明白其中的原理,可以戳这篇文章学习一下:layoutSubviews小结,否则可能会遇到一些奇怪的问题。
常见的一个问题就是在其中重新布局时,需要重绘view或者layer,重绘这一步本身没什么问题,但是一般我们后续都会不自觉地加上addSubview
和addSublayer
,这里add之后又会进入layoutSubviews
,结果就是循环,虽然不至于死锁,但是你会失去对图层的控制,一些图层将无法按照你的预期进行修改或者销毁,请务必注意!
所以我在这里调用的reloadNineBox
方法中,没有重绘操纵,只有修改布局。而对于修改布局,以往常用的layer的position属性在这里有点迷,似乎是一个相对位置,控制起来不直观。考虑到layer的圆圈和中心点是基于UIBezierPath
来绘制的,我索性就更新了这个path。
上述的layer圆圈和中心点在drawNineBox
中完成绘制,其中绘制所需的布局信息在setupBoundsAndFrame
中进行计算。并且信息存储在两个成员数组中,这两个数组非常重要:
//保存9个circleLayer的数组会随frame变化
NSMutableArray *_nineCirclesArr;
//保存9个中心点的位置,会随frame变化
NSArray *_boxCentersArr;
可以看到在initWithFrame:
和layoutSubviews
中都调用了setupBoundsAndFrame方法,这就是所谓实现了整个view能够根据用户对于frame的调整,自动配置内部各layer的位置,使整个view能够正确布局。
7.3 用户手势的状态
整个view需要区分不同的几个状态,从而实现各阶段的功能,我在这里通过枚举把状态分为四个:
- KMNineBoxStateNormal:普通状态,即初始的用户未进行任何触摸操作前的状态,同时在完成验证反馈结果后,需要返回这个状态。
- KMNineBoxStateTouched:触摸状态,表明用户正在绘制手势图案,被扫过的圆圈会高亮加粗,并且进行连线。
- KMNineBoxStatePassed:验证通过状态,在完成绘制后会立刻进行手势密码的验证,这里表明手势正确,通过验证。
- KMNineBoxStateFailed:验证失败状态,表明手势密码错误,同时被扫过的圆圈和连线变成红色,提升用户手势错误。
- (void)setNineBoxState:(KMNineBoxState)nineBoxState
方法中实现对不同状态的操作。
7.4 用户触摸点的位置判断
绘制九宫格手势时,需要适时标记被扫过的圆圈,使其高亮加粗,这就需要对用户的触摸点进行位置判断。
思路就是现获取用户手指的位置,然后再计算落在9个圆圈哪一个的“管辖范围”内。由于9个圆圈不相交,所以触摸点同一时刻只可能落在一个圆圈的范围内。
首先,通过UIResponder
的三个touch event获取手指的三个不同状态:
- 刚刚触碰到屏幕
- 在屏幕上移动
- 离开了屏幕
三个接口如下:
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event
{
CGPoint point = [[touches anyObject] locationInView:self];
KMNineBoxIndex index = [self checkLocationWithTouchPoint:point];
[self decorateCircleWithBoxIndex:index];
}
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
CGPoint point = [[touches anyObject] locationInView:self];
KMNineBoxIndex index = [self checkLocationWithTouchPoint:point];
[self decorateCircleWithBoxIndex:index];
}
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
// sth reset
...
...
}
可以看到在Began和Moved状态中进行了触摸点位置的判断。
- (KMNineBoxIndex)checkLocationWithTouchPoint:(CGPoint)touchPoint
方法中进行来上述操作。它会返回一个KMNineBoxIndex枚举类型,表明现在手中落在哪一个圆圈范围内,例如落在圆圈3中时,就会返回KMNineBoxIndex3。
而具体的判断落点的算法,我在实现时思路比较简单,就是计算触摸点与九个圆圈中心点距离,找出最短距离,在最短距离不大于圆圈半径的前提下,就认为触摸点落在这个圆的范围内。这里面的计算最短距离,甚至整个落点的判断都是可以进一步优化的,欢迎大家提出建议。
之后便可以根据这个index信息去修饰对应的圆圈,使其高亮加粗。通过- (void)decorateCircleWithBoxIndex:(KMNineBoxIndex)index
方法来进行。
7.5 跟踪用户触摸过的轨迹
完成扫过的圆圈高亮加粗后,我们还需要记录下这些被扫过的圆圈,以及它们的序列。目的就是为了能够在移动手指时,提供圆圈间的连线,并且在手势验证失败时,把这些圆圈加以红色。此外还要能够方便我们提取出手势的序列,在接口中提供用户使用。
这里是用了一个成员数组来记录上述信息:
// 保存九宫格序列的数组,会随触摸手势变化
NSMutableArray *_sequenceArr;
记录过程如下:
NSString *checkStr = [NSString stringWithFormat:@"%ld", circleIndex+1];//加1
if (![self checkString:checkStr isInArray:_sequenceArr]) {
// 不允许重复添加
[_sequenceArr addObject:checkStr];
}
其中结合- (BOOL)checkString:isInArray:
方法实现去重添加,因为手势是实时检测的,那么对于落点的判断结果就会实时反馈,对于某一个圆圈,我们只希望记录下一次,于是去重是必须的。
_sequenceArr
记录下了手势移动的轨迹序列,这也是一部非常关键的操作。
7.6 圆圈间的连接线
有了上述步骤记录下的轨迹序列,我们就能方便地画出连线了。思路比较简单,取出_sequenceArr
中的最后两个元素,作为最近扫过的两个点,在两点之间进行连线。
不过需要注意,我们不希望重复画线,即两点间已经有连线了,在下一次绘制时最新的两点没有变化时,就不需要再画线了,判断代码如下:
// 新的两点连线,才继续画
if ([KMMathHelper point1:currentBoxCenter EqualToPoint2:_currentBoxCenter] &&
[KMMathHelper point1:previousBoxCenter EqualToPoint2:_previousBoxCenter]) {
return;
}
此外,只有一个序列点时,也不画线,因为二维平面内两个点才确定一条直线嘛!
7.7 给予手势反馈结果
经过上述一系列步骤,我们的手势密码基本成型,接下来就只需要反馈验证的结果,以及提供接口给用户。
在手势密码本身的反馈显示中,如果密码验证成功,将会返回普通状态。而如果验证失败,则被扫过的圆圈会以红色提醒用户,代码如下:
if ([self.predefinedPassSeq isEqualToString:sequenceStr]) {
[self setNineBoxState:KMNineBoxStatePassed];
}
else {
[self setNineBoxState:KMNineBoxStateFailed];
}
而对于协议接口给予用户的信息,首先需要提取出手势序列,将其转化成数字1-9组成的字符串:
NSString *sequenceStr = @"";
for (int i = 0; i < [_sequenceArr count]; i++) {
NSString *tmp = [NSString stringWithFormat:@"%@", _sequenceArr[i]];
sequenceStr = [NSString stringWithFormat:@"%@%@", sequenceStr, tmp];
}
然后结合状态信息,一并通过接口提供给用户:
[self.delegate nineBoxDidFinishWithState:_nineBoxState passSequence:sequenceStr];
最后就是一些提升用户体验的步骤,比如自动重置KMNineBoxView的状态,控制用户触摸响应等等,这里就不再赘述了。
8. 总结
本文介绍了如何仿制一个支付宝的手势密码,涉及到了较多页面布局和手势检测的知识。不过让我感受最深的还是其中有一些计算技巧,需要平时的基础积累。这又再次提醒我:该回去补数据结构和算法啦!还不快ACM!
总之,希望这篇文章对大家有所帮助。更多iOS的知识,请继续关注后续的文章,感谢阅读!