IQKeyboardManager源码分析

写作原因:看三方库源码总不知道该看什么或者能学到什么,写文章无疑是最好的药;并且我在简书上搜了一下相关文章都不是很全面,所以就寻思着自己写一篇;本人能力有限,需要大家一起思考并完善本超长文

IQKeyboardManager简介

IQKeyboardManager是一个自动解决键盘遮挡输入源的库,输入源目前只有UITextView和UITextField;解决方法是让界面内容上移到合适位置让输入框在键盘之上

IQKeyboardManager集成

pod集成

只需要在Podfile中写上:

pod 'IQKeyboardManager', '~> 4.0.7'

然后加入工程:

 pod install --verbose --no-repo-update
文件夹拖入

IQKeyboardManager下载项目,然后把IQKeyboardManager文件夹拖入工程:

文件夹拖入.png
做了之后什么代码都不用写IQKeyboardManager就自动启动了,原理是因为IQKeyboardManager.m中重写了+(void)load
如果你不需要弹出键盘(你没有加toolbar的键盘)时附带IQKeyboardManager给你配置的toolbar,你可以在你配置文件中写上:

[IQKeyboardManager sharedManager].enableAutoToolbar = NO;

如果你需要在键盘弹出时能都点击键盘之外部分收起键盘,你可以在你配置文件中写上:

[IQKeyboardManager sharedManager].shouldResignOnTouchOutside = YES;

如果你需要在某个控制器禁用IQKeyboardManager,你可以在对应控制器中写上:

- (void)viewWillAppear:(BOOL)animated {
    [super viewWillAppear:animated];
    [[IQKeyboardManager sharedManager] setEnable:NO];
}
- (void)viewWillDisappear:(BOOL)animated {
    [super viewWillDisappear:animated];
    [[IQKeyboardManager sharedManager] setEnable:YES];
}

IQKeyboardManager初始化

IQKeyboardManager.m重写了+(void)load,里面启动了IQKeyboardManager:

[[IQKeyboardManager sharedManager] setEnable:YES];

现在我们来追踪sharedManager:

+ (IQKeyboardManager*)sharedManager{
    static IQKeyboardManager *kbManager;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        kbManager = [[self alloc] init];
    });
    return kbManager;
}

这是一个常见的单例写法,主要看看init:

__weak typeof(self) weakSelf = self;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
    __strong typeof(self) strongSelf = weakSelf;
    ...
});

这里最开始看我也觉得很迷惑,为什么这里还要写一个dispatch_once而且里面要强引用weakSelf,答案在这里,主要是多线程环境下防止self对象在后面的代码中被析构(析构:第一次是在C++中听说,其实就是被释放了)

[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(keyboardWillShow:) name:UIKeyboardWillShowNotification object:nil];            
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(keyboardDidShow:) name:UIKeyboardDidShowNotification object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(keyboardWillHide:) name:UIKeyboardWillHideNotification object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(keyboardDidHide:) name:UIKeyboardDidHideNotification object:nil];

这里是注册键盘弹出/收起的通知,很好理解

strongSelf.registeredClasses = [[NSMutableSet alloc] init];
[self registerTextFieldViewClass:[UITextField class]
      didBeginEditingNotificationName:UITextFieldTextDidBeginEditingNotification
      didEndEditingNotificationName:UITextFieldTextDidEndEditingNotification];
[self registerTextFieldViewClass:[UITextView class]
      didBeginEditingNotificationName:UITextViewTextDidBeginEditingNotification
      didEndEditingNotificationName:UITextViewTextDidEndEditingNotification];

这里要配合[self registerTextFieldViewClass:::]一起看:

-(void)registerTextFieldViewClass:(nonnull Class)aClass
  didBeginEditingNotificationName:(nonnull NSString *)didBeginEditingNotificationName
    didEndEditingNotificationName:(nonnull NSString *)didEndEditingNotificationName
{
    [_registeredClasses addObject:aClass];
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(textFieldViewDidBeginEditing:) name:didBeginEditingNotificationName object:nil];
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(textFieldViewDidEndEditing:) name:didEndEditingNotificationName object:nil];
}

可以得知目前registeredClasses只有UITextView和UITextField两个值,也就是文章前面提到的输入源了(也只有这两个控件可以弹出键盘);所以上面的代码是在注册哪些输入源需要解决键盘遮挡,并为其加上编辑的通知
从这里可以看到作者这样写是为了方便扩展,比如以后苹果新出了一个输入源只需要再调用一次[self registerTextFieldViewClass:::]即可,而且把所有的输入源编辑通知放到同一个回调中处理也是为了问题集中处理,方便以后修改和检查问题(而且看源码也知道这样做确实有很大好处)

[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(willChangeStatusBarOrientation:) name:UIApplicationWillChangeStatusBarOrientationNotification object:[UIApplication sharedApplication]];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didChangeStatusBarFrame:) name:UIApplicationDidChangeStatusBarFrameNotification object:[UIApplication sharedApplication]];

加上屏幕旋转和状态栏frame改变的通知,因为要重新计算位置等信息(考虑的太周全了);开始还想了一下状态栏frame什么时候会改变?后面想了想屏幕旋转后状态栏frame肯定会变啊,然后暂时想不起其他情况了

strongSelf.tapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(tapRecognized:)];
strongSelf.tapGesture.cancelsTouchesInView = NO;            
[strongSelf.tapGesture setDelegate:self];            
strongSelf.tapGesture.enabled = strongSelf.shouldResignOnTouchOutside;

这是初始化了一个单击手势(只是初始化出来,后面后根据输入源来添加到window上),在键盘弹起来后可以点击键盘外部分收起键盘,通过设置
[IQKeyboardManager sharedManager].shouldResignOnTouchOutside = YES/NO
开启和关闭此功能;然后cancelsTouchesInView设置为no可以看这里(知识点)

strongSelf.animationDuration = 0.25;

需要让屏幕滚动才能让输入源可见时,设置滚动动画执行的时间

strongSelf.animationCurve = UIViewAnimationCurveEaseInOut;

需要让屏幕滚动才能让输入源可见时,设置滚动动画的方式

[self setKeyboardDistanceFromTextField:10.0];

设置输入源底部距离键盘顶部的距离,默认为10,你可以改成100然后运行一下看看效果

[self setShouldPlayInputClicks:YES];

用到shouldPlayInputClicks属性的地方是用户点击IQKeyboardManager自带的toolbar中的上一个、下一个和完成按钮时,如果shouldPlayInputClicks为YES将执行如下代码:

[[UIDevice currentDevice] playInputClick]

这句话的作用是播放按键声音(哒、哒、哒)

[self setShouldResignOnTouchOutside:NO];

设置默认情况下点击键盘外面部分不收起键盘

[self setOverrideKeyboardAppearance:NO];
[self setKeyboardAppearance:UIKeyboardAppearanceDefault];

是否让IQKeyboardManager覆盖用户设置的输入源弹出键盘样式,并设置默认键盘样式

[self setEnableAutoToolbar:YES];

默认让没有toolbar的键盘使用默认的toolbar(如果用户自定义了toolbar就忽略,这点后面的源码我们会讲到)

[self setPreventShowingBottomBlankSpace:NO];

这个属性是为了解决一个bug,原因是因为滚动高度计算错误(具体原因我们后面源码来模拟),滚动过高导致界面和键盘之间有黑色区域;

[self setShouldShowTextFieldPlaceholder:YES];

也就是toolbar中间的文字是否在输入源是UITextField时显示UITextField的占位字符,看一下图就明白了:


效果.png
[self setToolbarManageBehaviour:IQAutoToolbarBySubviews];

这个属性就比较有意思了,我看了一下用到toolbarManageBehaviour的地方,大致得到意思是:自带的toolbar上有上一个/下一个按钮,那么IQKeyboardManager怎么知道我下一个/上一个应该跳到哪个输入源呢?里面有这个函数来获取所有可以跳的输入源:

-(NSArray*)responderViews(
...
)

那么他们跳转的先后顺序呢?我们就可以对照toolbarManageBehaviour枚举值来看:

IQAutoToolbarBySubviews:按照添加的先后顺序
IQAutoToolbarByTag:按照tag值大小
IQAutoToolbarByPosition:按照视图在界面上的位置
[self setLayoutIfNeededOnUpdate:NO];

咋一看,为什么要设置成NO呢?我改成了YES然后得到下面的gif图:


效果图.gif

有一个明显的不协调因为toolbar出现时会再计算一次高度,所以屏幕滚动了两次;所以这个值默认是NO,表示我动画更新界面的frame时不需要再执行一次layout(从源码中可以看出作者最开始是执行了的,后面出现了bug才关闭执行,这也是一种版本迭代的方法,能让使用者更明白问题的原因)

[self setShouldFixInteractivePopGestureRecognizer:YES];

这也是为了解决一个bug,具体为什么我们后面来讨论

strongSelf.disabledDistanceHandlingClasses = [[NSMutableSet alloc] initWithObjects:[UITableViewController class], nil];
strongSelf.enabledDistanceHandlingClasses = [[NSMutableSet alloc] init];

强制禁用/开启IQKeyboardManager的控制器,即便设置了enable

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

强制禁用/开启带toolbar键盘的控制器

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

前面提到toolbar上面的上一个/下一个该往哪里跳的顺序,需要用到toolbarManageBehaviour枚举值来决定,但是如果输入源的父类是toolbarPreviousNextAllowedClasses中的某一个则强制使用IQAutoToolbarBySubviews这个toolbarManageBehaviour枚举值;因为用其他的枚举值要出现一个bug说的是太复杂(看来做一个库真的是抠脑壳)

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

强制开启/禁用点击键盘外面收起键盘

 [self setShouldToolbarUsesTextFieldTintColor:NO];

是否让toolbar上面的上一个/下一个/完成按钮颜色使用输入源的tintColor;设置为NO就是黑色,我设置成YES截了一个图:


设置成YES的效果图.png

到这里我们初始化就走完了,接下来我们就在测试界面(我工程中的一个界面)中点击一下输入框让其弹出键盘,我们来看看IQKeyboardManager都做了哪些事情,先来看看我来做测试的界面:


做测试的界面.png
这是一个UINavigationController中的UIViewController,self.view加了一个view铺满屏幕,这个view加了一个UITableView铺满view,UITableView的第一行(一个UITextField)、地点行(一个UITextField)和详情说明行(一个UITextView)是可以作为输入源弹出键盘的,我们接下来就点击地点行来做测试;前面源码提到键盘弹出/消失和输入源编辑都是有通知的,我们在这些回调中都打上断点

怎样测试IQKeyboardManager

我大概想了一下,测试无非就两种情况
1:点击某个输入源第一次弹起键盘
2:在键盘已经弹起的时候切换输入源,这里要分两种情况
1)用户自己点击下一个输入源
2)用户使用toolbar的上一个/下一个按钮由IQKeyboardManager切换到下一个输入源
所以接下来我们就来测试;我们点击上一系列提到的测试界面中的地点行,分析各个逻辑

第一次弹起键盘

当点击地点行中的UITextField时,首先进入输入框已经开始编辑的通知:

-(void)textFieldViewDidBeginEditing:(NSNotification*)notification()

我们来分析里面的代码:

[self showLog:[NSString stringWithFormat:@"****** %@ started ******",NSStringFromSelector(_cmd)]];

_cmd表示当前执行的指令(也不太知道用什么词语恰当)名字,showLog是作者自己写的方法:

-(void)showLog:(NSString*)logString
{
    if (_enableDebugging)
    {
        NSLog(@"IQKeyboardManager: %@",logString);
    }
}

这样做了一个判断,来决定是否需要在终端打印当前执行的指令名称;而且这样做也有一个好处,如果用户需要把执行过的所有指令写入到文件,那么直接在这里写就好了

_textFieldView = notification.object;

把当前的输入源用_textFieldView保存起来;textFieldView是UIView类型,因为前面说过作者是把所有的输入源编辑通知在同一个回调处理

if (_overrideKeyboardAppearance == YES){
        UITextField *textField = (UITextField*)_textFieldView;
        if (textField.keyboardAppearance != _keyboardAppearance){
            textField.keyboardAppearance = _keyboardAppearance;
            [textField reloadInputViews];
        }
    }

是否让IQKeyboardManager覆盖输入源的键盘样式;keyboardAppearance是UITextInputTraits协议中方法,而UITextField和UITextView都实现了UITextInput协议,UITextInput实现了UIKeyInput协议,UIKeyInput实现了UITextInputTraits协议(好复杂):

@protocol UIKeyInput <UITextInputTraits>
if ([self privateIsEnableAutoToolbar])
-(BOOL)privateIsEnableAutoToolbar
{
    BOOL enableAutoToolbar = _enableAutoToolbar;
    UIViewController *textFieldViewController = [_textFieldView viewController];
    ...
    return enableAutoToolbar;
}

[_textFieldView viewController]是作者为UIView加的一个分类,用来获取输入源所在的控制器:

-(UIViewController*)viewController
{
    UIResponder *nextResponder =  self;
    do  {
        nextResponder = [nextResponder nextResponder];
        if ([nextResponder isKindOfClass:[UIViewController class]])
            return (UIViewController*)nextResponder;
    } while (nextResponder != nil);
    return nil;
}

判断当前输入源所在的控制器是否应许弹出toolbar,因为我设置了整个工程禁用此属性:

[IQKeyboardManager sharedManager].enableAutoToolbar = NO;

所以返回NO(你也可以进去看看,里面用到了强制使用(_disabledToolbarClasses)和强制启动toolbar(_enabledToolbarClasses)),然后到了:

[self removeToolbarIfRequired];
-(void)removeToolbarIfRequired
{
    NSArray *siblings = [self responderViews];
    for (UITextField *textField in siblings)
    {
        UIView *toolbar = [textField inputAccessoryView];  
        if ([textField respondsToSelector:@selector(setInputAccessoryView:)] &&
            ([toolbar isKindOfClass:[IQToolbar class]] && (toolbar.tag == kIQDoneButtonToolbarTag || toolbar.tag == kIQPreviousNextButtonToolbarTag)))
        {
            textField.inputAccessoryView = nil;
        }
    }
}
-(NSArray*)responderViews
{
    UIView *superConsideredView;
    for (Class consideredClass in _toolbarPreviousNextAllowedClasses){
        superConsideredView = [_textFieldView superviewOfClassType:consideredClass];
        if (superConsideredView != nil)
            break;
    }
    if (superConsideredView)    {
        return [superConsideredView deepResponderViews];
    }  else {
        NSArray *textFields = [_textFieldView responderSiblings];
        switch (_toolbarManageBehaviour)
        {
            case IQAutoToolbarBySubviews:
                return textFields;
                break;
            case IQAutoToolbarByTag:
                return [textFields sortedArrayByTag];
                break;
            case IQAutoToolbarByPosition:
                return [textFields sortedArrayByPosition];
                break;
            default:
                return nil;
                break;
        }
    }
}

判断是否需要删除IQKeyboardManager附带的toolbar,这里要注意的是responderViews中的_toolbarPreviousNextAllowedClasses,上一个系列我们说到_toolbarPreviousNextAllowedClasses目前有三个值UITableView、UICollectionView和IQPreviousNextView;如果输入源是在这三个之中,将采用另外一种方式来获取所有能够弹起键盘的输入源,所以针对本测试界面将得到下面的输入:

(lldb) po [self responderViews]
<__NSArrayM 0x618000053230>(
<UITextField: 0x7fca2b69a860; frame = (15 5; 300 39.5); text = '领导弟弟的和'; clipsToBounds = YES; opaque = NO; autoresize = RM+BM; gestureRecognizers = <NSArray: 0x610000258e10>; layer = <CALayer: 0x610000429240>>,
<UITextField: 0x7fca2b730fd0; frame = (15 5; 290 39.5); text = ''; clipsToBounds = YES; opaque = NO; autoresize = RM+BM; gestureRecognizers = <NSArray: 0x60000044bb50>; layer = <CALayer: 0x6080004257c0>>,
<UITextView: 0x7fca2fa21800; frame = (15 5; 290 89.5); text = ''; clipsToBounds = YES; autoresize = RM+BM; gestureRecognizers = <NSArray: 0x60000044e8e0>; layer = <CALayer: 0x60000023ba80>; contentOffset: {0, 0}; contentSize: {290, 81}>
)

然后IQKeyboardManager也只会删除自己本身创建的toolbar(通过[toolbar isKindOfClass:[IQToolbar class]就可以看出),然后继续执行到了:

if ([self privateIsEnabled] == NO){
        [self showLog:[NSString stringWithFormat:@"****** %@ ended ******",NSStringFromSelector(_cmd)]];
        return;
 }
-(BOOL)privateIsEnabled
{
    BOOL enable = _enable;
    UIViewController *textFieldViewController = [_textFieldView viewController];
    if (textFieldViewController)
    ...
    return enable;
}

这里是判断当前控制器是否启动了IQKeyboardManager,原理和上面是否开启toolbar是一样的,看了这里,我们是不是应该用新方式来禁用某个控制器启动IQKeyboardManager呢,你只需要在配置文件中写上:

...
[[IQKeyboardManager sharedManager].disabledDistanceHandlingClasses addObject:[XXXXXX class]];
...

测试界面返回YES,所以继续执行到了:

[_textFieldView.window addGestureRecognizer:_tapGesture];

还记得上一系列我们说到的吗?单击手势只是创建了,这里才加到输入源所在的window上,然后到了这个函数:

if (CGRectEqualToRect(_topViewBeginRect, CGRectZero)){
...
}

我们现在是第一次遇到_topViewBeginRect,这主要是解决UITextField和UITextView触发键盘通知和编辑通知先后不同的bug,所以这个条件为真,我们进入函数中执行:

_layoutGuideConstraintInitialConstant = [[[_textFieldView viewController] IQLayoutGuideConstraint] constant];

这里要注意IQLayoutGuideConstraint是什么东西,它是给UIViewController添加的属性,我们先想一下我们测试界面有添加这个吗?答案是:

(lldb) po [[_textFieldView viewController] IQLayoutGuideConstraint]
 nil

因为我们是代码创建的,作者添加这个是因为一个bug;我们后面会讲到,所以测试界面得到的_layoutGuideConstraintInitialConstant也是为0:

(lldb) po _layoutGuideConstraintInitialConstant
0

然后到了:

_rootViewController = [_textFieldView topMostController];
if (_rootViewController == nil) 
 _rootViewController = [[self keyWindow] topMostController];
#UIView (IQ_UIView_Hierarchy)
-(UIViewController *)topMostController
{
    NSMutableArray *controllersHierarchy = [[NSMutableArray alloc] init];
    UIViewController *topController = self.window.rootViewController;
    if (topController){
        [controllersHierarchy addObject:topController];
    }
    while ([topController presentedViewController]) {
        topController = [topController presentedViewController];
        [controllersHierarchy addObject:topController];
    }
    UIResponder *matchController = [self viewController];
    while (matchController != nil && [controllersHierarchy containsObject:matchController] == NO){
        do{
            matchController = [matchController nextResponder];
        } while (matchController != nil && [matchController isKindOfClass:[UIViewController class]] == NO);
    }
    return (UIViewController*)matchController;
}
#UIWindow (IQ_UIWindow_Hierarchy)
- (UIViewController*)topMostController
{
    UIViewController *topController = [self rootViewController];
    while ([topController presentedViewController]) topController = [topController presentedViewController];
    return topController;
}

这里就不需要解释的太复杂了,就是通过输入源响应链得到工程最顶层的控制器(其中要注意presentedViewController的理解),为了让你们更深刻的理解,我做了一个小实验,我在topMostController函数中的while前加一个假数据:

...
UIViewController *vc = [UIViewController new];
[self.window.rootViewController presentViewController:vc animated:NO completion:nil];
while ([topController presentedViewController]) {
...

然后得到的数组是:

(lldb) po controllersHierarchy
<__NSArrayM 0x61800024c630>(
<MainViewController: 0x7fa8781091e0>,
<UIViewController: 0x7fa875c66ab0>
)

最后_rootViewController的值是:

(lldb) po matchController
<MainViewController: 0x7fa8781091e0>

因为我在Appdelegate中写了(一般工程得到的都是self.window.rootViewController):

self.window.rootViewController = [[MainViewController alloc] initWithOptions:launchOptions];

继续执行代码,接下来是:

_topViewBeginRect = _rootViewController.view.frame

把顶层控制器的frame赋给_topViewBeginRect至于要做啥我们后面就会看到,我得到的是:

(lldb) po _topViewBeginRect
(origin = (x = 0, y = 0), size = (width = 320, height = 568))

接下来是如果顶层控制器是一个导航视图控制器将导致左滑返回实效的bug,需要重新计算一下frame:

if (_shouldFixInteractivePopGestureRecognizer && [_rootViewController isKindOfClass:[UINavigationController class]]){            
  _topViewBeginRect.origin = CGPointMake(0, [self keyWindow].frame.size.height-_rootViewController.view.frame.size.height);
}
[self showLog:[NSString stringWithFormat:@"Saving %@ beginning Frame: %@",[_rootViewController _IQDescription], NSStringFromCGRect(_topViewBeginRect)]];

然后执行到了:

if (_isKeyboardShowing == YES &&  _textFieldView != nil && [_textFieldView isAlertViewTextField] == NO){
        [self adjustFrame];
}
[self showLog:[NSString stringWithFormat:@"****** %@ ended ******",NSStringFromSelector(_cmd)]];

这里判断_isKeyboardShowing是因为UITextField和UITextView触发键盘通知和编辑通知先后不同,判断_textFieldView是防御性编程思想,判断isAlertViewTextField是因为一个bug(如果是UIAlertView里面的输入框,苹果已经帮你做好了键盘遮挡的处理;到了这里我们知道有两个地方是不需要解决键盘遮挡输入源的:UIAlertView里面的输入源和UITableViewController里面的输入源)目前_isKeyboardShowing为NO,所以这个通知我们就执行完了;然后进入了键盘将要显示的通知:

-(void)keyboardWillShow:(NSNotification*)aNotification{}

我们来分析一下逻辑,先是把通知赋给_kbShowNotification,至于什么用我们后面才知道:

_kbShowNotification = aNotification;

然后把键盘弹起的标志设置为YES,常规的判断当前控制器是否可用,然后判断_topViewBeginRect是否没有值,我们现在有了就不会执行了(主要还是因为UITextField和UITextView触发键盘通知和编辑通知先后不同做了很多的重复代码,记住!记住!!记住!!!):

_isKeyboardShowing = YES;
if ([self privateIsEnabled] == NO)  return;
[self showLog:[NSString stringWithFormat:@"****** %@ started ******",NSStringFromSelector(_cmd)]];
if (_textFieldView != nil && CGRectEqualToRect(_topViewBeginRect, CGRectZero)) {
...
}

然后执行到了:

_animationCurve = [[aNotification userInfo][UIKeyboardAnimationCurveUserInfoKey] integerValue];
_animationCurve = _animationCurve<<16;
CGFloat duration = [[aNotification userInfo][UIKeyboardAnimationDurationUserInfoKey] floatValue];
if (duration != 0.0)    _animationDuration = duration;

得到键盘弹出来的动画方式,保存动画执行的时间,时间我们初始化的时候设置成了0.25,这里如果键盘有动画时间我们会重新获取一次更准确一点:

CGSize oldKBSize = _kbSize;
CGRect kbFrame = [[aNotification userInfo][UIKeyboardFrameEndUserInfoKey] CGRectValue];
CGRect screenSize = [[UIScreen mainScreen] bounds];

oldKBSize主要是用在切换输入源的时候保持前一个的键盘frame,获取当前键盘的frame保存到kbFrame,获取屏幕frame保存到screenSize:

CGRect intersectRect = CGRectIntersection(kbFrame, screenSize);
if (CGRectIsNull(intersectRect)) {
    _kbSize = CGSizeMake(screenSize.size.width, 0);
}    else {
    _kbSize = intersectRect.size;
}

这也是一个bug我也遇到过,就是键盘没有完全的弹出来,所以作者机智的用CGRectIntersection()得到键盘和屏幕重叠的部分作为键盘的frame保存到_kbSize:

if (!CGSizeEqualToSize(_kbSize, oldKBSize)){
        if (_isKeyboardShowing == YES && _textFieldView != nil && [_textFieldView isAlertViewTextField] == NO){
            [self adjustFrame];
        }
    }

判断上一个输入源键盘的frame和现在的是不是一样,我们这里是不一样的因为我们现在是第一次弹出键盘:

(lldb) po _kbSize
(width = 320, height = 253)

(lldb) po oldKBSize
(width = 0, height = 0)

然后就是这个重点函数了,一共420+行:

-(void)adjustFrame{
...
}

我打算单独其一个系列来说,因为这个函数才是IQKeyboardManager的核心,其他的很多变量和操作都只是为了解决bug和定制功能而已

if (_textFieldView == nil)   return;
[self showLog:[NSString stringWithFormat:@"****** %@ started ******",NSStringFromSelector(_cmd)]];
//得到window对象
UIWindow *keyWindow = [self keyWindow];

首先还是一个常规的判断和日志打印,以及得到window对象

UIViewController *rootController = [_textFieldView topMostController];    
if (rootController == nil)  rootController = [keyWindow topMostController];

这个前面已经说到了,是获取顶层控制器

CGRect textFieldViewRect = [[_textFieldView superview] convertRect:_textFieldView.frame toView:keyWindow];
CGRect rootViewRect = [[rootController view] frame];

得到输入框在屏幕中的位置已经屏幕的位置,我么打印一下看看:

(lldb) po textFieldViewRect
(origin = (x = 15, y = 357.00999999977648), size = (width = 290, height = 39.5))
(lldb) po rootViewRect
(origin = (x = 0, y = 0), size = (width = 320, height = 568))

下面做了这么多,其实都是在计算输入源底部应该在键盘上面多少距离

CGFloat specialKeyboardDistanceFromTextField = _textFieldView.keyboardDistanceFromTextField;
if (_textFieldView.isSearchBarTextField) {
        UISearchBar *searchBar = (UISearchBar*)[_textFieldView superviewOfClassType:[UISearchBar class]];
        specialKeyboardDistanceFromTextField = searchBar.keyboardDistanceFromTextField;
}
CGFloat keyboardDistanceFromTextField = (specialKeyboardDistanceFromTextField == kIQUseDefaultKeyboardDistance)?_keyboardDistanceFromTextField:specialKeyboardDistanceFromTextField;

我们打印一下,也就是我们在init中设置的10:

(lldb) po keyboardDistanceFromTextField
10

得到键盘高度+ keyboardDistanceFromTextField的高度(也就是屏幕在输入源下面有多少高度),以及状态栏高度:

CGSize kbSize = _kbSize;
kbSize.height += keyboardDistanceFromTextField;
//状态栏frame
CGRect statusBarFrame = [[UIApplication sharedApplication] statusBarFrame];

我们还是打印一下:

(lldb) po statusBarFrame
(origin = (x = 0, y = 0), size = (width = 320, height = 20))
(lldb) po kbSize.height
263

下面做了这么多都是因为一个bug,最终目的是得到应该让视图往上移动多少(move变量):

IQLayoutGuidePosition layoutGuidePosition = IQLayoutGuidePositionNone;
    NSLayoutConstraint *constraint = [[_textFieldView viewController] IQLayoutGuideConstraint];    
    //http://blog.kyleduo.com/2014/10/22/ios_learning_autolayout_toplayoutguide/
    if (constraint.firstItem == [[_textFieldView viewController] topLayoutGuide] ||
        constraint.secondItem == [[_textFieldView viewController] topLayoutGuide]){
        layoutGuidePosition = IQLayoutGuidePositionTop;
    }
    else if (constraint.firstItem == [[_textFieldView viewController] bottomLayoutGuide] ||
             constraint.secondItem == [[_textFieldView viewController] bottomLayoutGuide]){
        layoutGuidePosition = IQLayoutGuidePositionBottom;
    }
    CGFloat topLayoutGuide = CGRectGetHeight(statusBarFrame);
    CGFloat move = 0;
    if (layoutGuidePosition == IQLayoutGuidePositionBottom){
        move = CGRectGetMaxY(textFieldViewRect)-(CGRectGetHeight(keyWindow.frame)-kbSize.height);
    } else{
        move = MIN(CGRectGetMinY(textFieldViewRect)-(topLayoutGuide+5), CGRectGetMaxY(textFieldViewRect)-(CGRectGetHeight(keyWindow.frame)-kbSize.height));
    }
[self showLog:[NSString stringWithFormat:@"Need to move: %.2f",move]];

这个bug讲到导致,如果控制器的view添加了topLayoutGuide或者bottomLayoutGuide将会失效,也就是这个


图.png

不过我们一般都不会用这个约束,所以我打印一下我应该移动多少:

(lldb) po move
91.509999999776483

下面是找到能够滚动的当前输入框的父亲视图superScrollView:

UIScrollView *superScrollView = nil;
UIScrollView *superView = (UIScrollView*)[_textFieldView superviewOfClassType:[UIScrollView class]];
    //得到能够滚动的滚动视图 上面找到的可能不能滚动
    while (superView) {
        if (superView.isScrollEnabled) {
            superScrollView = superView;
            break;
        }  else {
            superView = (UIScrollView*)[superView superviewOfClassType:[UIScrollView class]];
        }
    }

通过这个代码我们就可以解决一个整个window都上移动的问题,我们只需要把输入源的某一个父视图设置为滚动视图即可;我这里打印出来的当然是表格视图了:

(lldb) po superView
<UITableView: 0x7fe6d005fc00;
  frame = (0 0; 320 568);
  clipsToBounds = YES; 
  gestureRecognizers = <NSArray: 0x608000458f00>; 
  layer = <CALayer: 0x60800003cea0>; 
  contentOffset: {0, 0}; 
  contentSize: {320, 438.00999999977648}>
(lldb) po superScrollView
<UITableView: 0x7fe6d005fc00; 
  frame = (0 0; 320 568); 
  clipsToBounds = YES; 
  gestureRecognizers = <NSArray: 0x608000458f00>; 
  layer = <CALayer: 0x60800003cea0>; 
  contentOffset: {0, 0}; 
  contentSize: {320, 438.00999999977648}>

_lastScrollView这个属性我们还是第一次遇到,所以会执行else if(superScrollView)里面的代码:

_lastScrollView = superScrollView;
//得到需要设置偏移量的滚动视图的各种内容属性
_startingContentInsets = superScrollView.contentInset;
_startingContentOffset = superScrollView.contentOffset;
_startingScrollIndicatorInsets = superScrollView.scrollIndicatorInsets;
[self showLog:[NSString stringWithFormat:@"Saving %@ contentInset: %@ and contentOffset : %@",[_lastScrollView _IQDescription],NSStringFromUIEdgeInsets(_startingContentInsets),NSStringFromCGPoint(_startingContentOffset)]];

这里当然就是常规的操作了,把滚动视图赋值给_lastScrollView,获取滚动视图的一些属性以及打印日志

while (superScrollView &&  (move>0?(move > (-superScrollView.contentOffset.y-superScrollView.contentInset.top)):superScrollView.contentOffset.y>0) )
 {
...
}

这个while就是先找到一个父滚动视图,判断它能不能把所有的move都给偏移完,如果父滚动视图偏移达到最大了move还是大于0,就继续找父滚动视图的父滚动视图,依次类推;需要注意里面的这段代码:

if ([_textFieldView isKindOfClass:[UITextView class]] &&[superScrollView superviewOfClassType:[UIScrollView class]] == nil && (shouldOffsetY >= 0)) {
...
}

如果输入源是UITextView,并且输入源的父类(递归查找)都没有发现滚动视图,并且当前还需要滚动(shouldOffsetY表示界面还需要上/下移动多少,才能让键盘不遮挡输入源);就不需要继续递归查找滚动视图了,因为没有可以移动的视图,只需要重新计算偏移多少结束while循环

[UIView animateWithDuration:_animationDuration delay:0 options:(_animationCurve|UIViewAnimationOptionBeginFromCurrentState) animations:^{
...
 superScrollView.contentOffset = CGPointMake(superScrollView.contentOffset.x, shouldOffsetY);
} completion:NULL];

这里就是对输入源的父滚动视图进行偏移

lastView = superScrollView;
superScrollView = (UIScrollView*)[lastView superviewOfClassType:[UIScrollView class]];

偏移了当前滚动视图,我们继续偏移滚动视图的父滚动视图(如果有父滚动视图且move>0);当我们偏移完了(move=0或move>0且没有父滚动视图可以偏移)我们就进入到更新滚动视图contentInset的代码:

CGRect lastScrollViewRect = [[_lastScrollView superview] convertRect:_lastScrollView.frame toView:keyWindow];

得到输入源父滚动视图在window中的frame,我们打印一下值:

(lldb) po lastScrollViewRect
(origin = (x = 0, y = 64), size = (width = 320, height = 568))
CGFloat bottom = kbSize.height-keyboardDistanceFromTextField-(CGRectGetHeight(keyWindow.frame)-CGRectGetMaxY(lastScrollViewRect));

得到输入源底部距屏幕底部的距离,打印出来是317

UIEdgeInsets movedInsets = _lastScrollView.contentInset;
movedInsets.bottom = MAX(_startingContentInsets.bottom, bottom);

重新计算一下滚动视图的内容偏移contentInset,一般情况是不变的,只是在滚动视图底部进行偏移时会出问题,下面就是动画设置还有打印了
前面说到如果对控制器设置了TopLayoutGuide或者BottomLayoutGuide时会出问题,所以接下来就是对它们进行单独处理获取正确的约束值,因为我们当前界面没有设置,所以我们会运行else;

if ([_textFieldView isKindOfClass:[UITextView class]]) {
...
}

这个if是解决如果UITextView在屏幕中frame太大的问题

UITextView *textView = (UITextView*)_textFieldView;
 CGFloat textViewHeight = MIN(CGRectGetHeight(_textFieldView.frame), (CGRectGetHeight(keyWindow.frame)-kbSize.height-(topLayoutGuide)));

textViewHeight为:屏幕高度-键盘高度-TopLayoutGuide约束

if (_textFieldView.frame.size.height-textView.contentInset.bottom>textViewHeight) {
...
UIEdgeInsets newContentInset = textView.contentInset;
newContentInset.bottom = _textFieldView.frame.size.height-textViewHeight;
textView.contentInset = newContentInset;
textView.scrollIndicatorInsets = newContentInset;
...
}

如果高度实在太高,我们就设置UITextView的contentInset.bottom让UITextView内容增加时能始终在键盘上面显示,因为contentInset.bottom使得UITextView下面的区域不能输入内容

if ([rootController modalPresentationStyle] == UIModalPresentationFormSheet ||
[rootController modalPresentationStyle] == UIModalPresentationPageSheet) {
...
}

这是判断ipad,我们是iPhone,所以到了这里

if (move>=0) {
   rootViewRect.origin.y -= move;
 if (_preventShowingBottomBlankSpace == YES){
   rootViewRect.origin.y = MAX(rootViewRect.origin.y, MIN(0, -kbSize.height+keyboardDistanceFromTextField));
 }                
   [self showLog:@"Moving Upward"];
   [self setRootViewFrame:rootViewRect];
}

这里是一个比较精髓的地方,如果滚动视图(递归查找)都没有把move消费完(move还是大于0,也就是输入源还是被键盘遮挡住的) 就需要对window的frame进行设置了,我们来看看else执行了什么

CGFloat disturbDistance = CGRectGetMinY(rootViewRect)-CGRectGetMinY(_topViewBeginRect);                
if(disturbDistance<0)
{
    rootViewRect.origin.y -= MAX(move, disturbDistance);
    [self showLog:@"Moving Downward"];
    [self setRootViewFrame:rootViewRect];
}

move<0 说明输入源底部距离键盘顶部还有一定距离,说不定我们可以稍微恢复一下windw的frame(如果widnow的frame被设置过)
运行到这里,我们的第一次弹出键盘的情况就完了,我们来试着切换一下输入源看看

切换输入源

我们点击另外一个输入源,会直接进入

-(void)textFieldViewDidBeginEditing:(NSNotification*)notification

输入源将要开始编辑的回调,里面又去执行了一次adjustFrame解决键盘遮挡;然后到了

-(void)keyboardWillShow:(NSNotification*)aNotification {
...
if (!CGSizeEqualToSize(_kbSize, oldKBSize)) {
...
}
}
...

这里就比较有意思了,因为我们当前输入源和上一个输入源是一样的大小:

(lldb) po oldKBSize
(width = 320, height = 253)
(lldb) po _kbSize
(width = 320, height = 253)

所以不会执行adjustFrame,然后到了

- (void)keyboardDidShow:(NSNotification*)aNotification

这个回调几乎都会去再次执行adjustFrame方法,然后点击另外一个输入源回调就执行完了,我们现在消失键盘

消失键盘

我们点击done收起键盘,先进入

- (void)keyboardWillHide:(NSNotification*)aNotification

键盘将要消失的回调,其实这里面是在进行反操作了,就是恢复滚动视图偏移量啥的,如果改变了window的frame就恢复等等;其他的回调我就不说了,所以通过上面的分析我们可以得出结论了

结论

1:IQKeyboardManager在需要解决键盘遮挡时会去递归找可滚动的父视图进行偏移,如果没有就对window的frame做文章
2:核心方法是adjustFrame,通过它解决键盘遮挡
3:IQKeyboardManager考虑的非常全面,以至于里面很多的判断语句,主要是为了能使用于目前发现的任何情况
4:看源码主要是看一个思路,没必要去弄清楚每个小代码块啥意思
5:一个好的库最开始是很简单的,到后面慢慢的就会越写越复杂、越全面;所以看源码建议从最初的版本开始,这样才能知道作者为什么要加某些东西,加这些东西是为了什么

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 198,030评论 5 464
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,198评论 2 375
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 144,995评论 0 327
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,973评论 1 268
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,869评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,766评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,967评论 3 388
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,599评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,886评论 1 293
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,901评论 2 314
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,728评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,504评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,967评论 3 302
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,128评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,445评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,018评论 2 343
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,224评论 2 339

推荐阅读更多精彩内容