IQKeyboardManager("零行代码"解决键盘遮挡问题) 源码分析

项目中键盘遮挡输入框的问题我想一直都是让人头疼的问题 但是有了强大的IQKeyboardManager 这个框架 这个问题就不再是问题了

好了 以下就是本人对这个框架的一些理解

一 使用方法

这个框架的使用方法也是极其的简单
只需要将 IQKeyboardManager 加入 Podfile,然后 pod install 就可以了。

pod 'IQKeyboardManager'
// iOS delegate内应用入口

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {

    //关闭设置为NO, 默认值为NO.

    [IQKeyboardManager sharedManager ].enable = YES;

}

如果产品需要当键盘弹起时,点击背景收起键盘,也是一行代码解决。

[IQKeyboardManager sharedManager].shouldResignOnTouchOutside = YES;

而当产品需要支持内联编辑(Inline Editing), 这就需要隐藏键盘上的工具条(默认打开)

[IQKeyboardManager sharedManager].enableAutoToolbar = NO;

如果当某一个输入框特定不需要键盘上的工具条时,一行代码

textField.inputAccessoryView = [[UIView alloc] init];

如果因为不知名的原因需要在某个页面禁止自动键盘处理事件相应,也很简单。

- (void) viewWillAppear: (BOOL)animated {
         //打开键盘事件相应
          [IQKeyboardManager sharedManager].enable = YES;
}
- (void) viewWillDisappear: (BOOL)animated {
         //关闭键盘事件相应
          [IQKeyboardManager sharedManager].enable = NO;
}

二 源码分析

我们先看一下 IQKeyBoardManager中类的层级关系

image.png

整个项目中最核心的部分就是 IQKeyboardManager 这个类,它负责管理键盘出现或者隐藏时视图移动的距离,是整个框架中最核心的部分。

在这个框架中还有一些用于支持 IQKeyboardManager 的分类,以及显示在键盘上面的 IQToolBar:

image.png

使用红色标记的部分就是 IQToolBar,左侧的按钮可以在不同的 UITextField 之间切换,中间的文字是 UITextField.placeholderText,右边的 Done 应该就不需要解释了。

这篇文章会主要分析 IQKeyboardManager 中解决的问题,会用小篇幅介绍包含占位符(Placeholder) IQTextView 的实现。

(1). IQTextView的实现

在具体研究如何解决键盘遮挡问题之前,我们先分析一下框架中最简单的一部分 IQTextView 是如何为 UITextView 添加占位符的。

@interface IQTextView : UITextView

@end

IQTextView 继承自 UITextView,它只是在 UITextView 上添加上了一个 placeHolderLabel。

在初始化时,我们会为 UITextViewTextDidChangeNotification 注册通知:

- (void)initialize   {
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(refreshPlaceholder) name:UITextViewTextDidChangeNotification object:self];
}

在每次 UITextView 中的 text 更改时,就会调用 refreshPlaceholder 方法更新 placeHolderLabel 的 alpha 值来隐藏或者显示 label:

-(void)refreshPlaceholder {
    if ([[self text] length]) {
        [placeHolderLabel setAlpha:0];
    } else {
        [placeHolderLabel setAlpha:1];
    }
    
    [self setNeedsLayout];
    [self layoutIfNeeded];
}

IQKeyboardManager

下面就会进入这篇文章的正题:IQKeyboardManager。

如果你对 iOS 开发比较熟悉,可能会发现每当一个类的名字中包含了 manager,那么这个类可能可能遵循单例模式,IQKeyboardManager 也不例外

IQKeyboardManager 的初始化

当 IQKeyboardManager 初始化的时候,它做了这么几件事情:

1 监听有关键盘的通知
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(keyboardWillShow:) name:UIKeyboardWillShowNotification object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(keyboardWillHide:) name:UIKeyboardWillHideNotification object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(keyboardDidHide:) name:UIKeyboardDidHideNotification object:nil];
2 注册与 UITextField 以及 UITextView 有关的通知
[self registerTextFieldViewClass:[UITextField class]
 didBeginEditingNotificationName:UITextFieldTextDidBeginEditingNotification
   didEndEditingNotificationName:UITextFieldTextDidEndEditingNotification];

[self registerTextFieldViewClass:[UITextView class]
 didBeginEditingNotificationName:UITextViewTextDidBeginEditingNotification
   didEndEditingNotificationName:UITextViewTextDidEndEditingNotification];
 调用的方法将通知绑定到了 textFieldViewDidBeginEditing: 和 textFieldViewDidEndEditing: 方法上
- (void)registerTextFieldViewClass:(nonnull Class)aClass
  didBeginEditingNotificationName:(nonnull NSString *)didBeginEditingNotificationName
    didEndEditingNotificationName:(nonnull NSString *)didEndEditingNotificationName {
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(textFieldViewDidBeginEditing:) name:didBeginEditingNotificationName object:nil];
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(textFieldViewDidEndEditing:) name:didEndEditingNotificationName object:nil];
}
3 初始化一个 UITapGestureRecognizer,在点击 UITextField 对应的 UIWindow 的时候,收起键盘
strongSelf.tapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(tapRecognized:)];




- (void)tapRecognized:(UITapGestureRecognizer*)gesture {
    if (gesture.state == UIGestureRecognizerStateEnded)
        [self resignFirstResponder];
}
4 初始化一些默认属性,例如键盘距离、覆写键盘的样式等
strongSelf.animationDuration = 0.25;
strongSelf.animationCurve = UIViewAnimationCurveEaseInOut;
[self setKeyboardDistanceFromTextField:10.0];
[self setShouldPlayInputClicks:YES];
[self setShouldResignOnTouchOutside:NO];
[self setOverrideKeyboardAppearance:NO];
[self setKeyboardAppearance:UIKeyboardAppearanceDefault];
[self setEnableAutoToolbar:YES];
[self setPreventShowingBottomBlankSpace:YES];
[self setShouldShowTextFieldPlaceholder:YES];
[self setToolbarManageBehaviour:IQAutoToolbarBySubviews];
[self setLayoutIfNeededOnUpdate:NO];
5 设置不需要解决键盘遮挡问题的类
strongSelf.disabledDistanceHandlingClasses = [[NSMutableSet alloc] initWithObjects:[UITableViewController class], nil];
strongSelf.enabledDistanceHandlingClasses = [[NSMutableSet alloc] init];

strongSelf.disabledToolbarClasses = [[NSMutableSet alloc] init];
strongSelf.enabledToolbarClasses = [[NSMutableSet alloc] init];

strongSelf.toolbarPreviousNextAllowedClasses = [[NSMutableSet alloc] initWithObjects:[UITableView class],[UICollectionView class],[IQPreviousNextView class], nil];

strongSelf.disabledTouchResignedClasses = [[NSMutableSet alloc] init];
strongSelf.enabledTouchResignedClasses = [[NSMutableSet alloc] init];

基于通知的解决方案

在这里,我们以 UITextField 为例,分析方法的调用流程

在初始化方法中,我们注册了很多的通知,包括键盘的出现和隐藏,UITextField 开始编辑与结束编辑。

在这些通知响应时,会执行以下的方法

通知对应的方法
UIKeyboardWillShowNotification ------>  @selector(keyboardWillShow:)

UIKeyboardWillHideNotification ------> @selector(keyboardWillHide:)

UIKeyboardDidHideNotification ------> @selector(keyboardDidHide:)

UITextFieldTextDidBeginEditingNotification ------> @selector(textFieldViewDidBeginEditing:)

UITextFieldTextDidEndEditingNotification ------> @selector(textFieldViewDidEndEditing:)

整个解决方案其实都是基于 iOS 中的通知系统的;在事件发生时,调用对应的方法做出响应。

开启 Debug 模式

在阅读源代码的过程中,我发现 IQKeyboardManager 提供了 enableDebugging 这一属性,可以通过开启它,来追踪方法的调用,我们可以在 Demo 加入下面这行代码

[IQKeyboardManager sharedManager].enableDebugging = YES;

运行demo

image.png

点击图中的textField 上边的操作会打印出如下所示的log

IQKeyboardManager: ****** textFieldViewDidBeginEditing: started ******
IQKeyboardManager: adding UIToolbars if required
IQKeyboardManager: Saving <UINavigationController 0x7f905b01b000> beginning Frame: {{0, 0}, {320, 568}}
IQKeyboardManager: ****** adjustFrame started ******
IQKeyboardManager: Need to move: -451.00
IQKeyboardManager: ****** adjustFrame ended ******
IQKeyboardManager: ****** textFieldViewDidBeginEditing: ended ******
IQKeyboardManager: ****** keyboardWillShow: started ******
IQKeyboardManager: ****** adjustFrame started ******
IQKeyboardManager: Need to move: -154.00
IQKeyboardManager: ****** adjustFrame ended ******
IQKeyboardManager: ****** keyboardWillShow: ended ******

我们可以通过分析 - textFieldViewDidBeginEditing: 以及 - keyboardWillShow: 方法来了解这个项目的原理。

textFieldViewDidBeginEditing:

当 UITextField 被点击时,方法 - textFieldViewDidBeginEditing: 被调用,但是注意这里的方法并不是代理方法,它只是一个跟代理方法同名的方法,根据 Log,它做了三件事情:

1为 UITextField 添加 IQToolBar
2在调整 frame 前,保存当前 frame,以备之后键盘隐藏后的恢复
3调用 - adjustFrame 方法,将视图移动到合适的位置

添加 ToolBar

添加 ToolBar 是通过方法 - addToolbarIfRequired实现的,在 - textFieldViewDidBeginEditing: 先通过 - privateIsEnableAutoToolbar判断 ToolBar 是否需要添加,再使用相应方法 - addToolbarIfRequired实现这一目的。

这个方法会根据根视图上 UITextField 的数量执行对应的代码,下面为一般情况下执行的代码:

- (void)addToolbarIfRequired {
    NSArray *siblings = [self responderViews];
    for (UITextField *textField in siblings) {
        [textField addPreviousNextDoneOnKeyboardWithTarget:self previousAction:@selector(previousAction:) nextAction:@selector(nextAction:) doneAction:@selector(doneAction:) shouldShowPlaceholder:_shouldShowTextFieldPlaceholder];
        textField.inputAccessoryView.tag = kIQPreviousNextButtonToolbarTag;

        IQToolbar *toolbar = (IQToolbar*)[textField inputAccessoryView];
        toolbar.tintColor = [UIColor blackColor];
        [toolbar setTitle:textField.drawingPlaceholderText];
        [textField setEnablePrevious:NO next:YES];
    }

在键盘上的 IQToolBar 一般由三部分组成:
1 切换 UITextField 的箭头按钮
2 指示当前 UITextField 的 placeholder
3 Done Button

image.png

这些 item 都是 IQBarButtonItem 的子类

这些 IQBarButtonItem 以及IQToolBar 都是通过方法 - addPreviousNextDoneOnKeyboardWithTarget:previousAction:nextAction:doneAction:或者类似方法添加的:

- (void)addPreviousNextDoneOnKeyboardWithTarget:(id)target previousAction:(SEL)previousAction nextAction:(SEL)nextAction doneAction:(SEL)doneAction titleText:(NSString*)titleText {
    IQBarButtonItem *prev = [[IQBarButtonItem alloc] initWithImage:imageLeftArrow style:UIBarButtonItemStylePlain target:target action:previousAction];
    IQBarButtonItem *next = [[IQBarButtonItem alloc] initWithImage:imageRightArrow style:UIBarButtonItemStylePlain target:target action:nextAction];
    IQTitleBarButtonItem *title = [[IQTitleBarButtonItem alloc] initWithTitle:self.shouldHideTitle?nil:titleText];
    IQBarButtonItem *doneButton =[[IQBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemDone target:target action:doneAction];

    IQToolbar *toolbar = [[IQToolbar alloc] init];
    toolbar.barStyle = UIBarStyleDefault;
    toolbar.items = @[prev, next, title, doneButton];
    toolbar.titleInvocation = self.titleInvocation;
    [(UITextField*)self setInputAccessoryView:toolbar];
}

上面是方法简化后的实现代码,初始化需要的 IQBarButtonItem,然后将这些 IQBarButtonItem 全部加入到 IQToolBar上,最后设置 UITextField accessoryView

保存 frame

这一步的主要目的是为了在键盘隐藏时恢复到原来的状态,其实现也非常简单:

_rootViewController = [_textFieldView topMostController];
_topViewBeginRect = _rootViewController.view.frame;

获取 topMostController,在 _topViewBeginRect 中保存 frame

adjustFrame

在上述的任务都完成之后,最后就需要调用 - adjustFrame方法来调整当前根试图控制器的frame了:

我们只会研究一般情况下的实现代码,因为这个方法大约有 400 行代码对不同情况下的实现有不同的路径,包括有 lastScrollView、含有 superScrollView 等等。

而这里会省略绝大多数情况下的实现代码。

我们只会研究一般情况下的实现代码,因为这个方法大约有 400 行代码对不同情况下的实现有不同的路径,包括有 lastScrollView、含有 superScrollView 等等。
而这里会省略绝大多数情况下的实现代码。

- (void)adjustFrame {
    UIWindow *keyWindow = [self keyWindow];
    UIViewController *rootController = [_textFieldView topMostController];    
    CGRect textFieldViewRect = [[_textFieldView superview] convertRect:_textFieldView.frame toView:keyWindow];
    CGRect rootViewRect = [[rootController view] frame];
    CGSize kbSize = _kbSize;
    kbSize.height += keyboardDistanceFromTextField;
    CGFloat topLayoutGuide = CGRectGetHeight(statusBarFrame);
    CGFloat move = MIN(CGRectGetMinY(textFieldViewRect)-(topLayoutGuide+5), CGRectGetMaxY(textFieldViewRect)-(CGRectGetHeight(keyWindow.frame)-kbSize.height));
    
    if (move >= 0) {
        rootViewRect.origin.y -= move;
        [self setRootViewFrame:rootViewRect];
    } else {
        CGFloat disturbDistance = CGRectGetMinY(rootViewRect)-CGRectGetMinY(_topViewBeginRect);
        if (disturbDistance < 0) {
            rootViewRect.origin.y -= MAX(move, disturbDistance);
            [self setRootViewFrame:rootViewRect];
        }
    }
}

方法 - adjustFrame的工作分为两部分
1 计算 move的距离

2 调用 - setRootViewFrame:方法设置 rootView 的大小

- (void)setRootViewFrame:(CGRect)frame {
    UIViewController *controller = [_textFieldView topMostController];    
    frame.size = controller.view.frame.size;

    [UIView animateWithDuration:_animationDuration delay:0 options:(_animationCurve|UIViewAnimationOptionBeginFromCurrentState) animations:^{
        [controller.view setFrame:frame];
    } completion:NULL];
}

不过,在 - textFieldViewDidBeginEditing: 的调用栈中,并没有执行 - setRootViewFrame: 来更新视图的大小,因为点击最上面的 UITextField 时,不需要移动视图就能保证键盘不会遮挡 UITextField。

keyboardWillShow:

上面的代码都是在键盘出现之前执行的,而这里的 - keyboardWillShow:方法的目的是为了保证键盘出现之后,依然没有阻挡 UITextField

因为每一个 UITextField对应的键盘大小可能不同,所以,这里通过检测键盘大小是否改变,来决定是否调用 - adjustFrame 方法更新视图的大小。

- (void)keyboardWillShow:(NSNotification*)aNotification {
    _kbShowNotification = aNotification;
    
    _animationCurve = [[aNotification userInfo][UIKeyboardAnimationCurveUserInfoKey] integerValue];
    _animationCurve = _animationCurve<<16;
    CGFloat duration = [[aNotification userInfo][UIKeyboardAnimationDurationUserInfoKey] floatValue];
    if (duration != 0.0)    _animationDuration = duration;
    
    CGSize oldKBSize = _kbSize;
    CGRect kbFrame = [[aNotification userInfo][UIKeyboardFrameEndUserInfoKey] CGRectValue];
    CGRect screenSize = [[UIScreen mainScreen] bounds];
    CGRect intersectRect = CGRectIntersection(kbFrame, screenSize);

    if (CGRectIsNull(intersectRect)) {
        _kbSize = CGSizeMake(screenSize.size.width, 0);
    } else {
        _kbSize = intersectRect.size;
    }
 
    if (!CGSizeEqualToSize(_kbSize, oldKBSize)) {
        [self adjustFrame];
    }
}

- adjustFrame方法调用之前,执行了很多代码都是用来保存一些关键信息的,比如通知对象、动画曲线、动画时间。

最关键的是更新键盘的大小,然后比较键盘的大小 CGSizeEqualToSize(_kbSize, oldKBSize) 来判断是否执行 - adjustFrame 方法。

因为 - adjustFrame 方法的结果是依赖于键盘大小的,所以这里对 - adjustFrame 是有意义并且必要的。

键盘的隐藏

通过点击 IQToolBar 上面的done按钮,键盘就会隐藏

image.png

键盘隐藏的过程中会依次调用下面的三个方法:

  • - keyboardWillHide
  • - textFieldViewDidEndEditing:
  • - keyboardDidHide:
IQKeyboardManager: ****** keyboardWillHide: started ******
IQKeyboardManager: Restoring <UINavigationController 0x7fbaa4009e00> frame to : {{0, 0}, {320, 568}}
IQKeyboardManager: ****** keyboardWillHide: ended ******
IQKeyboardManager: ****** textFieldViewDidEndEditing: started ******
IQKeyboardManager: ****** textFieldViewDidEndEditing: ended ******
IQKeyboardManager: ****** keyboardDidHide: started ******
IQKeyboardManager: ****** keyboardDidHide: ended ******

键盘在收起时,需要将视图恢复至原来的位置,而这也就是 - keyboardWillHide:方法要完成的事情:

[strongSelf.rootViewController.view setFrame:strongSelf.topViewBeginRect]

在重新设置视图的大小以及位置之后,会对之前保存的属性进行清理:

_lastScrollView = nil;
_kbSize = CGSizeZero;
_startingContentInsets = UIEdgeInsetsZero;
_startingScrollIndicatorInsets = UIEdgeInsetsZero;
_startingContentOffset = CGPointZero;

而之后调用的两个方法 - textFieldViewDidEndEditing:以及- keyboardDidHide:也只做了很多简单的清理工作,包括添加到 window上的手势,并重置保存的UITextField 和视图的大小。

- (void)textFieldViewDidEndEditing:(NSNotification*)notification{
    [_textFieldView.window removeGestureRecognizer:_tapGesture];
    _textFieldView = nil;
}

- (void)keyboardDidHide:(NSNotification*)aNotification {
    _topViewBeginRect = CGRectZero;
}

小结

IQKeyboardManager 使用通知机制来解决键盘遮挡输入框的问题,因为使用了分类并且在IQKeyboardManager 的 + load 方法中激活了框架的使用,所以达到了零行代码解决这一问题的效果。

虽然 IQKeyboardManager很好地解决了这一问题、为我们带来了良好的体验。不过,由于其涉及 UI 层级;并且需要考虑非常多的边界以及特殊条件,框架的代码不是很容易阅读,但是这不妨碍IQKeyboardManager 成为非常优秀的开源项目

<部分借鉴网络>

推荐阅读更多精彩内容