最近在做用户登录获取验证码时添加图形验证码功能,就是只有正确输入图形验证码才能收到后台发送的短信验证码。效果如下:
看起来虽然是个小功能,但是实际操作起来,会发现苹果给我们留下的坑,当然更多的是自己给自己挖的坑。本文就是解决这个小功能出现的最麻烦的一个坑。
先介绍图中四个输入框的业务逻辑:
1、每个输入框都不能手动点击成为第一响应者,只能通过键盘输入控制,也就是只能前进和后退;
2、输入框全部输入且正确就请求后台获取相应手机的短信验证码,请求失败则在视图中显示失败信息;
主要的功能就这两点,当然还有更细节的地方就不作考虑。然后在此过程中,发现了一个非常致命的坑:由于后台给的图形验证码是纯数字的,然后我定义的四个输入框限制了键盘类型都为数字键盘,然后问题是当输入框没有任何文字时,点击键盘的delete(✘)键没有任何效果,从代码的角度来讲就是textField的代理和点击事件中没有一个方法能够监听到这个回调,那么我所要实现的输入框无文字点击delete键使后一个textField成为第一响应者的功能就变得毫无可能。真是自作孽不可活啊,当然有很多方法可以直接跳过这个bug,比如换一个输入框的实现方式:只用一个textField,然后在textField视图上叠加四个label或是别的能显示文字的视图。或者还是原来的实现方式,只不过要自定义数字键盘(不建议这么做,系统的键盘做了很多特殊处理,如键盘优先级、通知方法等等,自己实现会花很多功夫)。当然,不止这些方法可以实现这个功能,只是作为程序员的我怎么能后避过眼前的bug呢?就是这样一个不服输的精神终于让我想到了一个惊为天人的实现技巧。
说明:本文bug只适用于系统数字键盘,普通键盘是完全不会出现的,其他键盘我未作测试,请看清本文意图。
接下来就来说明技巧的实现方式:
该技巧的精髓是亦幻亦真,蒙蔽用户的眼睛。
既然在textField无文字时无法监听到数字键盘的delete键,那么我另辟蹊径,始终让textField有文字,但是也不显示,那就是使用“ ”(一个空格字符串)来代替nil(空字符串)。当用户每输入一个数字,让下一个textField获取焦点,与此同时,给下一textField文字赋上空格字符串,那么该textField就同时具备了再次输入和监听键盘delete键的特性;当该textField点击了delete键时,让上一个textField获取焦点,并给其文字赋上空格字符串。如此循环往复,就能完成多个textField的焦点切换。但与此同时产生的问题,下一个textField在没有输入之前就已经有了空格字符串,当输入时,文字就不再居中而是往后偏移了一个字符的宽度。当然,这怎么能难得了我:原本的每个textField都是有焦点的光标闪动的,现在我让此光标不可见,然后在输入数字的同时将原来的“空格+数字”字符串替换为本次输入的数字就可以了。
废话有点多了,直接上代码。
首先我新建了一个类,继承UITextField,目的是拦截用户点击,是点击变得不可响应。
//
// YTUnclickableTextField.m
// 分时租赁
//
// Created by chips on 17/3/27.
// Copyright © 2017年 柯其谱. All rights reserved.
//#import "YTUnclickableTextField.h"NSString * const YTUnclickableTextFieldSpace = @" ";@implementation YTUnclickableTextField- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {return nil;
}- (BOOL)becomeFirstResponder {self.text = YTUnclickableTextFieldSpace;return [super becomeFirstResponder];
}@end
该类重写了两个系统方法:第一个用于拦截用户点击以此确认点击的目标view,直接返回nil后该该textField的示例就无法响应点击事件,但焦点依然可以代码获取。有人就说了,直接将enabled或userInteractionEnabled属性设置为NO就可以了。我的回答是绝对不行,设置任何一个属性为NO不仅会导致不能响应用户点击事件,而且textField的焦点都无法获取,亲测。第一个方法只是在每一个textField获取焦点时给文本赋值为空格字符串,并将该字符串设为外部变量,好让图形验证码view作下一步判断。
接下来是重头,图形验证码自定义view类:
//
// PicVerifyCodeView.m
// 分时租赁
//
// Created by chips on 17/3/24.
// Copyright © 2017年 柯其谱. All rights reserved.
//#import "PicVerifyCodeView.h"
#import "YTUnclickableTextField.h"
#import "YTHttpTool.h"
#import "Masonry.h"static NSInteger const kPicVerifyCodeNumber = 4;@interface PicVerifyCodeView () <UITextFieldDelegate>/** 请求图形验证码图片的url字符串 */
@property (nonatomic, copy) NSString *imageUrlString;
/** 验证码错误label */
@property (nonatomic, strong) UILabel *errorLabel;
/** 图形验证码imageView */
@property (nonatomic, strong) UIImageView *verifyCodeImageView;
/** 再生成图形验证码button */
@property (nonatomic, strong) UIButton *regenerateButton;@end@implementation PicVerifyCodeView#pragma mark - setter and getter
- (NSMutableArray<YTUnclickableTextField *> *)textFields {if (_textFields == nil) {_textFields = [NSMutableArray array];}return _textFields;
}#pragma mark - Construction method
- (instancetype)initWithFrame:(CGRect)frame tel:(NSString *)tel delegate:(id<PicVerifyCodeViewDelegate>)delegate {if (self = [super initWithFrame:frame]) {self.backgroundColor = [UIColor colorWithRed:0.2 green:0.2 blue:0.2 alpha:0.8];[self setupSubviews];self.imageUrlString = [NSString stringWithFormat:@"%@:%@/Account/ValidateCode?Tel=%@", YTHttpToolURLString, YTHttpToolPort, tel];[self generateVerCode];self.tel = tel;self.delegate = delegate;}return self;
}#pragma mark - Setup
- (void)setupSubviews {UIView *view = [[UIView alloc]init];[self addSubview:view];view.backgroundColor = [UIColor whiteColor];view.layer.cornerRadius = 10;CGFloat cancelImageViewW = 16;CGFloat margin = 16;UIImageView *cancelImageView = [[UIImageView alloc]init];[view addSubview:cancelImageView];cancelImageView.image = [UIImage imageNamed:@"chacha"];cancelImageView.userInteractionEnabled = YES;UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc]initWithTarget:self action:@selector(tapCancelImageView:)];[cancelImageView addGestureRecognizer:tap];CGFloat labelH = 30;UILabel *label = [[UILabel alloc]init];[view addSubview:label];label.text = @"请输入图形验证码";label.textAlignment = NSTextAlignmentCenter;label.font = [UIFont systemFontOfSize:18];UILabel *errorLabel = [[UILabel alloc]init];[view addSubview:errorLabel];self.errorLabel = errorLabel;errorLabel.textColor = [UIColor redColor];errorLabel.textAlignment = NSTextAlignmentCenter;errorLabel.font = [UIFont systemFontOfSize:12];UIView *picView = [[UIView alloc]init];[view addSubview:picView];UIButton *button = [[UIButton alloc]init];self.regenerateButton = button;[view addSubview:button];button.backgroundColor = AppStyleColor;[button setImage:[UIImage imageNamed:@"sx"] forState:UIControlStateNormal];[button addTarget:self action:@selector(clickRegenerateButton) forControlEvents:UIControlEventTouchUpInside];UIImageView *picImageView = [[UIImageView alloc]init];self.verifyCodeImageView = picImageView;[picView addSubview:picImageView];UIView *textFieldsView = [[UIView alloc]init];[view addSubview:textFieldsView];for (int i = 0; i < kPicVerifyCodeNumber; i++) {YTUnclickableTextField *textField = [[YTUnclickableTextField alloc]init];[self.textFields addObject:textField];[textFieldsView addSubview:textField];textField.textAlignment = NSTextAlignmentCenter;textField.keyboardType = UIKeyboardTypeNumberPad;textField.tintColor = [UIColor clearColor];[[self class]setupBorderColor:textField];textField.layer.cornerRadius = 5;textField.layer.borderWidth = 1;textField.delegate = self;[textField addTarget:self action:@selector(editingChangedWith:) forControlEvents:UIControlEventEditingChanged];if (i == 0) {[textField becomeFirstResponder];textField.layer.borderColor = AppStyleColor.CGColor;}}[view mas_makeConstraints:^(MASConstraintMaker *make) {make.leading.equalTo(super.mas_leading).with.offset(40);make.top.equalTo(super.mas_top).with.offset(130);make.centerX.equalTo(super.mas_centerX);make.height.equalTo(view.mas_width).with.dividedBy(1.3);}];[cancelImageView mas_makeConstraints:^(MASConstraintMaker *make) {make.top.equalTo(view.mas_top).with.offset(margin);make.trailing.equalTo(view.mas_trailing).with.offset(-margin);make.width.mas_equalTo(cancelImageViewW);make.height.equalTo(cancelImageView.mas_width);}];[label mas_makeConstraints:^(MASConstraintMaker *make) {make.top.equalTo(cancelImageView.mas_bottom);make.leading.equalTo(view.mas_leading);make.trailing.equalTo(view.mas_trailing);make.height.mas_equalTo(labelH);}];[errorLabel mas_makeConstraints:^(MASConstraintMaker *make) {make.top.equalTo(label.mas_bottom);make.height.mas_equalTo(30);make.leading.equalTo(label.mas_leading);make.trailing.equalTo(label.mas_trailing);}];CGFloat picMargin = 16;[picView mas_makeConstraints:^(MASConstraintMaker *make) {make.top.equalTo(errorLabel.mas_bottom).with.offset(8);make.leading.equalTo(view.mas_leading).with.offset(cancelImageViewW+margin);make.trailing.equalTo(cancelImageView.mas_leading);make.bottom.equalTo(textFieldsView.mas_top).offset(-picMargin);}];[button mas_makeConstraints:^(MASConstraintMaker *make) {make.top.equalTo(picView.mas_top);make.trailing.equalTo(picView.mas_trailing);make.bottom.equalTo(picView.mas_bottom);make.width.equalTo(button.mas_height);}];[picImageView mas_makeConstraints:^(MASConstraintMaker *make) {make.top.equalTo(picView.mas_top);make.leading.equalTo(picView.mas_leading);make.trailing.equalTo(button.mas_leading);make.bottom.equalTo(picView.mas_bottom);}];[textFieldsView mas_makeConstraints:^(MASConstraintMaker *make) {make.height.equalTo(picView.mas_height);make.leading.equalTo(picView.mas_leading);make.trailing.equalTo(picView.mas_trailing);make.bottom.equalTo(view.mas_bottom).with.offset(-picMargin);}];CGFloat textFieldInset = 10;WeakSelf[self.textFields enumerateObjectsUsingBlock:^(YTUnclickableTextField * _Nonnull textField, NSUInteger idx, BOOL * _Nonnull stop) {[textField mas_makeConstraints:^(MASConstraintMaker *make) {make.top.equalTo(textFieldsView.mas_top);make.height.equalTo(textField.mas_width);if (idx == 0) {make.leading.equalTo(textFieldsView.mas_leading);make.trailing.equalTo(weakSelf.textFields[idx+1].mas_leading).with.offset(-textFieldInset);} else if (idx == self.textFields.count-1) {make.width.equalTo(weakSelf.textFields.firstObject.mas_width);make.trailing.equalTo(textFieldsView.mas_trailing);} else {make.width.equalTo(weakSelf.textFields.firstObject.mas_width);make.trailing.equalTo(weakSelf.textFields[idx+1].mas_leading).with.offset(-textFieldInset);}}];}];
}#pragma mark - Event response
- (void)tapCancelImageView:(UITapGestureRecognizer *)sender {[self removeFromSuperview];
}- (void)clickRegenerateButton {[self generateVerCode];
}- (void)editingChangedWith:(UITextField *)sender {if (![sender isFirstResponder]) {return;}if (!sender.text.length) {sender.text = YTUnclickableTextFieldSpace;} else {[self.textFields enumerateObjectsUsingBlock:^(YTUnclickableTextField * _Nonnull textField, NSUInteger idx, BOOL * _Nonnull stop) {if (sender == textField) {//最后一个输入框获取焦点if (idx == self.textFields.count-1) {//获取完整的图形验证码NSMutableString *verCode = [NSMutableString string];for (UITextField *tf in self.textFields) {[verCode appendString:tf.text];}//将self和完整输入的验证码传入delegateif ([self.delegate respondsToSelector:@selector(textFieldsDidEndEditing:verCode:)]) {[self.delegate textFieldsDidEndEditing:self verCode:verCode];}} else {[self.textFields[idx+1] becomeFirstResponder];[[self class] setupBorderColor:textField];[[self class] setupBorderColor:self.textFields[idx+1]];}*stop = YES;}}];}
}#pragma mark - UITextFieldDelegate
- (BOOL)textField:(UITextField *)textField shouldChangeCharactersInRange:(NSRange)range replacementString:(NSString *)string {if (!textField.isFirstResponder) {return NO;}if (string.length) {textField.text = nil;} else {[self.textFields enumerateObjectsUsingBlock:^(YTUnclickableTextField * _Nonnull iTextField, NSUInteger idx, BOOL * _Nonnull stop) {if (iTextField == textField) {if (idx > 0 && [iTextField.text isEqualToString:YTUnclickableTextFieldSpace]) {[self.textFields[idx-1] becomeFirstResponder];[[self class] setupBorderColor:textField];[[self class] setupBorderColor:self.textFields[idx-1]];}//消除错误label文字if (self.errorLabel.text) {[self showErrorCodeText:nil];}*stop = YES;}}];}return YES;
}#pragma mark - Private method
+ (void)setupBorderColor:(UITextField *)textField {textField.layer.borderColor = textField.isFirstResponder ? AppStyleColor.CGColor : [UIColor lightGrayColor].CGColor;
}- (void)generateVerCode {self.verifyCodeImageView.image = [UIImage imageWithData:[NSData dataWithContentsOfURL:[NSURL URLWithString:self.imageUrlString]]];
}#pragma mark - Public method
- (void)showErrorCodeText:(NSString *)text {self.errorLabel.text = text;
}@end
跳过以上繁杂的布局代码,只看textField的事件响应(编辑改变监听不是手动点击)方法editingChangedWith:和代理方法textField:shouldChangeCharactersInRange: :,设置这两个方法的目的是分别负责textField焦点的前进后退。
代码就不多加解释了,如若感兴趣或者有疑问可在下方评论,我会一一作出解答。