支付類應用通常都需要輸入6位支付密碼,有的是用 AlertView 的方式實現的,例如在 iPhone的 App Store 中下載應用,有時會彈窗讓你輸入密碼,界面大概是這樣的:
還有一種類似這樣的,把輸入框放在單獨的頁面上:
支付寶微信做的是類似半模態視圖的界面(下面的是淘票票的輸入密碼界面):
網上搜尋了許久,找了個最接近第三種方式的 Demo :
CYPasswordView_Block 源碼解析
一、概述
CYPasswordView_Block 是一個模仿支付寶輸入支付密碼的密碼框(這是一個用block傳遞事件的版本,避免了因多級頁面切換并且都彈出密碼框而造成通知監聽混亂的bug)
二、使用方法
- (IBAction)showPasswordView:(id)sender {
WS(weakSelf);
// 實例化 CYPasswordView 對象
self.passwordView = [[CYPasswordView alloc] init];
// 彈出密碼框
[self.passwordView showInView:self.view.window];
self.passwordView.loadingText = @"提交中...";
// 關閉密碼框
self.passwordView.cancelBlock = ^{
[weakSelf cancel];
};
// 忘記密碼
self.passwordView.forgetPasswordBlock = ^{
[weakSelf forgetPWD];
};
// 密碼輸入完成的回調
self.passwordView.finish = ^(NSString *password) {
[weakSelf.passwordView hidenKeyboard];
[weakSelf.passwordView startLoading];
CYLog(@"cy ========= 發送網絡請求 pwd=%@", password);
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(kRequestTime * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
flag = !flag;
if (flag) {
// 購買成功,跳轉到成功頁
[weakSelf.passwordView requestComplete:YES message:@"購買成功……"];
[weakSelf.passwordView stopLoading];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(kDelay * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[weakSelf.passwordView hide];
});
} else {
// 購買失敗,跳轉到失敗頁
[weakSelf.passwordView requestComplete:NO];
[weakSelf.passwordView stopLoading];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(kDelay * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[weakSelf.passwordView hide];
});
}
});
};
}
三、實現方式
需要哪些控件元素?
照著圖片看需要哪些控件元素,自上而下,從左往右看:
- 灰色的背景圖層 coverView,用于覆蓋上一個視圖;
- 占據半個屏幕的輸入框視圖,該視圖上有:
- 標題文本;
- 關閉按鈕、忘記密碼按鈕;
- 帶6個格子的密碼輸入框;
- 加載圖片、加載文本
如何顯示密碼?
- 鍵盤上輸入數字時,并不會實時顯示123456...,而是顯示 ●●●●●●, 這是為了密碼輸入安全起見的一貫做法。為 UITextField 設置
secureTextEntry
屬性為 YES。可以禁用用戶在視圖中復制文本的功能。 還會用* 代替輸入的字符。 - 但是原生的控件無法實現在一個格子里輸入一個字符,而且還要在這個格子中心顯示●,所以密碼輸入的6個方框和●都是假象,是用代碼畫上去的。
- 每次在 UITextField 中輸入、刪除數字,都要進行判斷,根據 UITextField 中文本的個數同步添加或刪除●
- 邊界情況,當輸入的數字個數大于等于6個,禁止輸入。
- 為了提高用戶體驗,當輸入完第6個數字時,自動關閉鍵盤,發起網絡請求以驗證密碼。
CYPasswordView_Block 源碼分析
CYPasswordView代碼結構
-
CYPasswordView.bundle
圖片庫,包含所有圖片素材
-
CYConst
通用宏定義、密碼框各控件的尺寸常量
-
UIView+Extension
簡化設置視圖尺寸的范疇方法
-
CYPasswordInputView
密碼框的輸入背景視圖(把
password_background@2x.png
、password_textfield@2x.png
兩個背景圖片繪制上去),主要包含標題、關閉按鈕、忘記密碼按鈕、以及繪制6個●的操作。 -
CYPasswordView
包含蒙版圖層、CYPasswordInputView、UITextField 控件、再添加加載圖片、加載文字標簽、處理判斷輸入邏輯、加載顯示旋轉動畫等
前三個不多說了,主要看后兩個
CYPasswordInputView
在指定初始化方法 initWithFrame:
中添加 關閉按鈕 和 忘記密碼按鈕,然后在 layoutSubviews
方法中對這兩個控件重新布局,按鈕的 Target-Action 點擊事件通過 Blocks 傳遞。
#pragma mark - 生命周期方法
- (instancetype)initWithFrame:(CGRect)frame {
if (self = [super initWithFrame:frame]) {
self.backgroundColor = [UIColor clearColor];
/** 添加子控件 */
[self setupSubViews];
}
return self;
}
/** 添加子控件 */
- (void)setupSubViews {
/** 關閉按鈕 */
UIButton *btnCancel = [UIButton buttonWithType:UIButtonTypeCustom];
[self addSubview:btnCancel];
[btnCancel setBackgroundImage:[UIImage imageNamed:CYPasswordViewSrcName(@"password_close")] forState:UIControlStateNormal];
[btnCancel setTitleColor:[UIColor darkGrayColor] forState:UIControlStateNormal];
self.btnClose = btnCancel;
[self.btnClose addTarget:self action:@selector(btnClose_Click:) forControlEvents:UIControlEventTouchUpInside];
/** 忘記密碼按鈕 */
UIButton *btnForgetPWD = [UIButton buttonWithType:UIButtonTypeCustom];
[self addSubview:btnForgetPWD];
[btnForgetPWD setTitle:@"忘記密碼?" forState:UIControlStateNormal];
[btnForgetPWD setTitleColor:CYColor(0, 125, 227) forState:UIControlStateNormal];
btnForgetPWD.titleLabel.font = CYFont(13);
[btnForgetPWD sizeToFit];
self.btnForgetPWD = btnForgetPWD;
[self.btnForgetPWD addTarget:self action:@selector(btnForgetPWD_Click:) forControlEvents:UIControlEventTouchUpInside];
}
- (void)layoutSubviews {
[super layoutSubviews];
// 設置關閉按鈕的坐標
self.btnClose.width = CYPasswordViewCloseButtonWH;
self.btnClose.height = CYPasswordViewCloseButtonWH;
self.btnClose.x = CYPasswordViewCloseButtonMarginLeft;
self.btnClose.centerY = CYPasswordViewTitleHeight * 0.5;
// 設置忘記密碼按鈕的坐標
self.btnForgetPWD.x = CYScreenWidth - (CYScreenWidth - CYPasswordViewTextFieldWidth) * 0.5 - self.btnForgetPWD.width;
self.btnForgetPWD.y = CYPasswordViewTitleHeight + CYPasswordViewTextFieldMarginTop + CYPasswordViewTextFieldHeight + CYPasswordViewForgetPWDButtonMarginTop;
}
// 按鈕點擊
- (void)btnClose_Click:(UIButton *)sender {
if (self.cancelBlock) {
self.cancelBlock();
}
[self.inputNumArray removeAllObjects];
}
- (void)btnForgetPWD_Click:(UIButton *)sender {
if (self.forgetPasswordBlock) {
self.forgetPasswordBlock();
}
}
在 drawRect:
方法中把圖片畫在圖層上,標題也并不是 Label 控件,而是畫上去的字符串,6個●也同時被畫上去了,它是遍歷數組中元素的個數畫上去的。
所以說,每次在輸入框中刪除、添加數字,都會通過調用 [self setNeedsDisplay]
將 drawRect:
方法中的所有視圖重新繪制一遍,目的是需要遍歷數組,重畫●
- (void)drawRect:(CGRect)rect {
// 畫背景視圖
UIImage *imgBackground =
[UIImage imageNamed:CYPasswordViewSrcName(@"password_background")];
[imgBackground drawInRect:rect];
// 畫輸入框
CGFloat textfieldY = CYPasswordViewTitleHeight + CYPasswordViewTextFieldMarginTop;
CGFloat textfieldW = CYPasswordViewTextFieldWidth;
CGFloat textfieldX = (CYScreenWidth - textfieldW) * 0.5;
CGFloat textfieldH = CYPasswordViewTextFieldHeight;
UIImage *imgTextfield =
[UIImage imageNamed:CYPasswordViewSrcName(@"password_textfield")];
[imgTextfield drawInRect:CGRectMake(textfieldX, textfieldY, textfieldW, textfieldH)];
// 畫標題
NSString *title = (self.title ? self.title : @"輸入交易密碼");
NSDictionary *arrts = @{NSFontAttributeName:CYFontB(18)};
CGSize size = [title boundingRectWithSize:CGSizeMake(MAXFLOAT, MAXFLOAT)
options:NSStringDrawingUsesLineFragmentOrigin
attributes:arrts
context:nil].size;
CGFloat titleW = size.width;
CGFloat titleH = size.height;
CGFloat titleX = (self.width - titleW) * 0.5;
CGFloat titleY = (CYPasswordViewTitleHeight - titleH) * 0.5;
CGRect titleRect = CGRectMake(titleX, titleY, titleW, titleH);
NSMutableDictionary *attr = [NSMutableDictionary dictionary];
attr[NSFontAttributeName] = CYFontB(18);
attr[NSForegroundColorAttributeName] = CYColor(102, 102, 102);
[title drawInRect:titleRect withAttributes:attr];
// 畫點
UIImage *pointImage =
[UIImage imageNamed:CYPasswordViewSrcName(@"password_point")];
CGFloat pointW = CYPasswordViewPointnWH;
CGFloat pointH = CYPasswordViewPointnWH;
CGFloat pointY = textfieldY + (textfieldH - pointH) * 0.5;
__block CGFloat pointX;
// 一個格子的寬度
CGFloat cellW = textfieldW / kNumCount;
CGFloat padding = (cellW - pointW) * 0.5;
// 根據數組中元素的個數畫黑點
[self.inputNumArray enumerateObjectsUsingBlock:^(id obj, NSUInteger idx, BOOL *stop) {
pointX = textfieldX + (2 * idx + 1) * padding + idx * pointW;
[pointImage drawInRect:CGRectMake(pointX, pointY, pointW, pointH)];
}];
}
首先來看在類擴展中聲明的可變數組:
/** 保存用戶輸入的數字集合 */
@property (nonatomic, strong) NSMutableArray *inputNumArray;
響應輸入的兩個方法
#pragma mark - 私有方法
// 響應用戶按下刪除鍵事件
- (void)deleteNumber {
[self.inputNumArray removeLastObject]; // 如果是刪除鍵,就移除數組中的最后一個數字
[self setNeedsDisplay]; // 根據數組的個數重繪整個視圖
}
// 響應用戶按下數字鍵事件
- (void)number:(NSDictionary *)userInfo {
NSString *numObj = userInfo[CYPasswordViewKeyboardNumberKey];
if (numObj.length >= kNumCount) return; // ?? 這行代碼應該是永遠不會執行的吧。等會再說為啥
[self.inputNumArray addObject:numObj]; // 這里每次輸入一個數字,就會把該數字存進數組中
[self setNeedsDisplay]; // 根據數組的個數重繪整個視圖
}
其實可以不用重繪整個視圖,因為只是重置小圓點,可以重繪指定區域視圖:setNeedsDisplayInRect:
// 響應用戶按下刪除鍵事件
- (void)deleteNumber {
[self.inputNumArray removeLastObject];
[self setNeedsDisplayInRect:[self textFieldRect]];
}
// 響應用戶按下數字鍵事件
- (void)number:(NSDictionary *)userInfo {
NSString *numObj = userInfo[CYPasswordViewKeyboardNumberKey];
if (numObj.length >= kNumCount) return;
[self.inputNumArray addObject:numObj];
[self setNeedsDisplayInRect:[self textFieldRect]];
}
- (CGRect)textFieldRect {
CGFloat textfieldY = CYPasswordViewTitleHeight + CYPasswordViewTextFieldMarginTop;
CGFloat textfieldW = CYPasswordViewTextFieldWidth;
CGFloat textfieldX = (CYScreenWidth - textfieldW) * 0.5;
CGFloat textfieldH = CYPasswordViewTextFieldHeight;
return CGRectMake(textfieldX, textfieldY, textfieldW, textfieldH);
}
CYPasswordView
再看 CYPasswordView 這個類,指定初始化方法中添加了幾個子視圖、蒙版、輸入框、響應者 UITextField、加載圖片和加載文字、單擊手勢。布局子視圖方法 layoutSubviews
中重新布局了加載圖片和加載文字標簽的位置。
- (instancetype)initWithFrame:(CGRect)frame {
self = [super initWithFrame:CYScreen.bounds]; // frame 是固定的
if (self) {
[self setupUI];
}
return self;
}
- (void)setupUI {
self.backgroundColor = [UIColor clearColor];
// 蒙版
[self addSubview:self.coverView];
// 輸入框
[self addSubview:self.passwordInputView];
// 響應者
[self addSubview:self.txfResponsder];
[self.passwordInputView addSubview:self.imgRotation];
[self.passwordInputView addSubview:self.lblMessage];
// 手勢
[self addGestureRecognizer:[[UITapGestureRecognizer alloc]
initWithTarget:self
action:@selector(tap:)]];
}
- (void)layoutSubviews {
[super layoutSubviews];
// 調整旋轉圖片布局
self.imgRotation.centerX = self.passwordInputView.centerX;
self.imgRotation.centerY = self.passwordInputView.height * 0.5;
// 調整提示文本布局
self.lblMessage.x = 0;
self.lblMessage.y = CGRectGetMaxY(self.imgRotation.frame) + 20;
self.lblMessage.width = CYScreenWidth;
self.lblMessage.height = 30;
}
- ① 添加單擊手勢的作用:點擊輸入框,彈出鍵盤
- (void)tap:(UITapGestureRecognizer *)recognizer {
// 獲取點擊的坐標位置
CGPoint p = [recognizer locationInView:self.passwordInputView];
// 6塊矩形輸入框區域
CGRect f = CGRectMake(39, 80, 297, 50);
// 判斷點擊區域是否包含在輸入框區域中
if (CGRectContainsPoint(f, p)) {
[self.txfResponsder becomeFirstResponder];
} else {
NSLog(@"==============");
}
}
因為要響應點擊事件,所以蒙版是一個 UIControl
- (UIControl *)coverView {
if (_coverView == nil) {
_coverView = [[UIControl alloc] init];
[_coverView setBackgroundColor:[UIColor blackColor]];
_coverView.alpha = 0.4;
_coverView.frame = self.bounds;
}
return _coverView;
}
- ② 為何要手動通過點擊事件彈出鍵盤,而不是點擊 UITextField 自動彈出鍵盤 ?
// 這里的 UITextField 實例對象就像名字一樣,只是作為一個響應者,
// 它的 frame 是 CGRectMake(0, 0, 1, 1)
// 優化點:其實還是可以把它設置得跟圖片一樣大的,只要把它設置為透明就行了,背景色、text、tintColor、通通設置為透明色,1.可以充分利用控件本身的特性。2.不用手動添加點擊事件。
- (UITextField *)txfResponsder {
if (_txfResponsder == nil) {
_txfResponsder = [[UITextField alloc] init];
_txfResponsder.delegate = self;
_txfResponsder.keyboardType = UIKeyboardTypeNumberPad;
_txfResponsder.frame = CGRectMake(0, 0, 1, 1);
_txfResponsder.secureTextEntry = YES;
}
return _txfResponsder;
}
這個類中實現取消按鈕和忘記密碼按鈕依舊是通過 Block 的方式傳遞,只是增加了重置密碼框操作
- (CYPasswordInputView *)passwordInputView {
WS(weakself);
if (_passwordInputView == nil) {
_passwordInputView = [[CYPasswordInputView alloc] init];
_passwordInputView.cancelBlock = ^{
[weakself cancel];
};
_passwordInputView.forgetPasswordBlock = ^{
[weakself forgetPassword];
};
}
return _passwordInputView;
}
/** 輸入框的取消按鈕點擊 */
- (void)cancel {
[self resetPasswordView];
if (self.cancelBlock) {
self.cancelBlock();
}
}
/** 輸入框的忘記密碼按鈕點擊 */
- (void)forgetPassword {
[self resetPasswordView];
if (self.forgetPasswordBlock) {
self.forgetPasswordBlock();
}
}
/** 重置密碼框 */
- (void)resetPasswordView {
[self hidenKeyboard:^(BOOL finished) {
self.passwordInputView.hidden = YES;
tempStr = nil;
[self removeFromSuperview];
[self hidenKeyboard:nil];
[self.passwordInputView setNeedsDisplay];
}];
}
處理鍵盤輸入的 UITextFieldDelegate
協議方法:
#pragma mark - 常量區
// 前面的角落里聲明了一個變量用戶保存密碼字符串
static NSString *tempStr;
//...
#pragma mark - <UITextFieldDelegate>
#pragma mark 處理字符串 和 刪除鍵
// 每當一個字符被鍵入時,都會首先調用此方法,詢問是否應該將輸入的字符添加進 TextField 中。
// 因此調用該方法時,正被輸入的字符實際上還沒有被添加進 TextField 中
- (BOOL)textField:(UITextField *)textField shouldChangeCharactersInRange:(NSRange)range replacementString:(NSString *)string {
if (!tempStr) {
tempStr = string;
}else{
// 把正在輸入的數字和之前所有輸入的數字拼接起來
// 因此 tempStr 字符串中存放的是所有的數字
tempStr = [NSString stringWithFormat:@"%@%@",tempStr,string];
}
// 判斷是否是刪除按鈕?
if ([string isEqualToString:@""]) {
// 響應用戶按下刪除鍵事件,刪除小黑點
[self.passwordInputView deleteNumber];
// 如果是刪除按鈕,再判斷容器字符串的長度?
if (tempStr.length > 0) { // 刪除最后一個字符串
NSString *lastStr = [tempStr substringToIndex:[tempStr length] - 1];
tempStr = lastStr;
}
} else{
// 如果不是刪除按鈕,先判斷容器字符串的長度是否=6?
if (tempStr.length == 6) {
if (self.finish) {
self.finish(tempStr);
self.finish = nil;
}
tempStr = nil;
}
// 更新 CYPasswordInputView 中的黑點
// ??把當前輸入的數字通過字典的方式傳遞,key為常量,value 是當前輸入的數字
NSMutableDictionary *userInfoDict = [NSMutableDictionary dictionary];
userInfoDict[CYPasswordViewKeyboardNumberKey] = string;
[self.passwordInputView number:userInfoDict];
}
return YES;
}
??因為當前輸入的數字是通過鍵值對的方式發送給 passwordInputView
對象的,所以它響應用戶按下數字的方法中:
// 響應用戶按下數字鍵事件
- (void)number:(NSDictionary *)userInfo {
NSString *numObj = userInfo[CYPasswordViewKeyboardNumberKey];
if (numObj.length >= kNumCount) return; // ??
[self.inputNumArray addObject:numObj]; // 這里每次輸入一個數字,就會把該數字存進數組中
[self setNeedsDisplay]; // 根據數組的個數重繪整個視圖
}
方法中的 numObj
就是當前輸入的數字,numObj.length
永遠等于1,所以我說這行代碼是永遠不會執行的。
彈出密碼框的方法:
// 彈出密碼框
[self.passwordView showInView:self.view.window];
源碼:
- (void)showInView:(UIView *)view {
[view addSubview:self];
/** 輸入框起始frame */
self.passwordInputView.height = CYPasswordInputViewHeight;
self.passwordInputView.y = self.height;
self.passwordInputView.width = CYScreenWidth;
self.passwordInputView.x = 0;
/** 彈出鍵盤 */
[self showKeyboard];
}
/** 鍵盤彈出 */
- (void)showKeyboard {
[self.txfResponsder becomeFirstResponder];
[UIView animateWithDuration:CYPasswordViewAnimationDuration
delay:0
options:UIViewAnimationOptionTransitionNone
animations:^{
self.passwordInputView.y = (self.height - self.passwordInputView.height);
}
completion:^(BOOL finished) {
NSLog(@" ========= %@", NSStringFromCGRect(self.passwordInputView.frame));
}];
}
這段代碼添加了一個動畫讓 passwordInputView
的 Y 點從:
self.height
→ (self.height - self.passwordInputView.height)
就是從底部向上推出的動畫,它是先彈出鍵盤,再彈出passwordInputView
。
最后,旋轉動畫都是對加載圖片和加載文字作了一些更改:
/**
* 開始旋轉
*/
- (void)startRotation:(UIView *)view {
_imgRotation.hidden = NO;
_imgRotation.image =
[UIImage imageNamed:CYPasswordViewSrcName(@"password_loading_b")];
_lblMessage.hidden = NO;
self.lblMessage.text = _loadingText;
CABasicAnimation* rotationAnimation;
rotationAnimation = [CABasicAnimation animationWithKeyPath:@"transform.rotation.z"];
rotationAnimation.toValue = [NSNumber numberWithFloat: M_PI * 2.0 ];
rotationAnimation.duration = 2.0;
rotationAnimation.cumulative = YES;
rotationAnimation.repeatCount = MAXFLOAT;
[view.layer addAnimation:rotationAnimation forKey:@"rotationAnimation"];
}
/**
* 結束旋轉
*/
- (void)stopRotation:(UIView *)view {
[view.layer removeAllAnimations];
}
- (void)startLoading {
[self startRotation:self.imgRotation];
[self.passwordInputView disEnalbeCloseButton:NO];
}
- (void)stopLoading {
[self stopRotation:self.imgRotation];
[self.passwordInputView disEnalbeCloseButton:YES];
}
- (void)requestComplete:(BOOL)state {
if (state) {
[self requestComplete:state message:@"支付成功"];
} else {
[self requestComplete:state message:@"支付失敗"];
}
}
- (void)requestComplete:(BOOL)state message:(NSString *)message {
if (state) {
// 請求成功
self.lblMessage.text = message;
self.imgRotation.image =
[UIImage imageNamed:CYPasswordViewSrcName(@"password_success")];
} else {
// 請求失敗
self.lblMessage.text = message;
self.imgRotation.image =
[UIImage imageNamed:CYPasswordViewSrcName(@"password_error")];
}
}
分析完以上源碼后,我又對該項目重構并進行了些許修改: