密碼輸入框:CYPasswordView_Block 源碼解析

支付類應用通常都需要輸入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];
                });
            }
        });
    };
}

三、實現方式


需要哪些控件元素?

照著圖片看需要哪些控件元素,自上而下,從左往右看:

  1. 灰色的背景圖層 coverView,用于覆蓋上一個視圖;
  2. 占據半個屏幕的輸入框視圖,該視圖上有:
    • 標題文本;
    • 關閉按鈕、忘記密碼按鈕;
    • 帶6個格子的密碼輸入框;
    • 加載圖片、加載文本

如何顯示密碼?

  1. 鍵盤上輸入數字時,并不會實時顯示123456...,而是顯示 ●●●●●●, 這是為了密碼輸入安全起見的一貫做法。為 UITextField 設置 secureTextEntry 屬性為 YES。可以禁用用戶在視圖中復制文本的功能。 還會用* 代替輸入的字符。
  2. 但是原生的控件無法實現在一個格子里輸入一個字符,而且還要在這個格子中心顯示●,所以密碼輸入的6個方框和●都是假象,是用代碼畫上去的。
  3. 每次在 UITextField 中輸入、刪除數字,都要進行判斷,根據 UITextField 中文本的個數同步添加或刪除●
  4. 邊界情況,當輸入的數字個數大于等于6個,禁止輸入。
  5. 為了提高用戶體驗,當輸入完第6個數字時,自動關閉鍵盤,發起網絡請求以驗證密碼。

CYPasswordView_Block 源碼分析

CYPasswordView代碼結構

  • CYPasswordView.bundle

    圖片庫,包含所有圖片素材

  • CYConst

    通用宏定義、密碼框各控件的尺寸常量

  • UIView+Extension

    簡化設置視圖尺寸的范疇方法

  • CYPasswordInputView

    密碼框的輸入背景視圖(把 password_background@2x.pngpassword_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")];
    }
}

分析完以上源碼后,我又對該項目重構并進行了些許修改:

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • 內容抽屜菜單ListViewWebViewSwitchButton按鈕點贊按鈕進度條TabLayout圖標下拉刷新...
    皇小弟閱讀 46,903評論 22 665
  • 發現 關注 消息 iOS 第三方庫、插件、知名博客總結 作者大灰狼的小綿羊哥哥關注 2017.06.26 09:4...
    肇東周閱讀 12,252評論 4 61
  • 漫步在林蔭大道上的兩個人, 思緒像插上了一對展開的翅膀。 不知是什么的原因, 兩人如同高山上小松鼠般的目光, 若即...
    唯愛12閱讀 312評論 0 0
  • 大年初三開始播出《三生三世十里桃花》,我偶爾瞟了幾眼。 直到第8集趙夜華上線,才開啟了追劇模式。 目前劇情已經進行...
    早_哇閱讀 1,582評論 3 7
  • 昨日忘記打卡,早起補上,因文章看的少,留言“昨天助攻的不多”,下面就有小伙伴疑問了“昨日上墻人數很多“。看了兩遍,...
    豆子121閱讀 287評論 19 11