iOS『零行代码』解决键盘遮挡问题
MarvinHam
8年前
<p>这篇文章会对 IQKeyboardManager 自动解决键盘遮挡问题的方法进行分析。</p> <p>最近在项目中使用了 <a href="/misc/goto?guid=4958968202817889243" rel="nofollow,noindex">IQKeyboardManager</a> 来解决 UITextField 被键盘遮挡的问题,这个框架的使用方法可以说精简到了极致,只需要将 IQKeyboardManager 加入 Podfile ,然后 pod install 就可以了。</p> <pre> <code class="language-objectivec">pod 'IQKeyboardManager' </code></pre> <p>这篇文章的题目《零行代码解决键盘遮挡问题》来自于开源框架的介绍:</p> <p>Codeless drop-in universal library allows to prevent issues of keyboard sliding up and cover UITextField/UITextView. Neither need to write any code nor any setup required and much more.</p> <p>因为在项目中使用了 IQKeyboardManager,所以,我想通过阅读其源代码来了解这个黑箱是如何工作的。</p> <p>虽然这个框架的实现的方法是比较简单的,不过它的实现代码不是很容易阅读,框架因为包含了很多与 UI 有关的实现细节,所以代码比较复杂。</p> <h2>架构分析</h2> <p>说是架构分析,其实只是对 <a href="/misc/goto?guid=4958968202817889243" rel="nofollow,noindex">IQKeyboardManager</a> 中包含的类以及文件有一个粗略地了解,研究一下这个项目的层级是什么样的。</p> <p><img src="https://simg.open-open.com/show/f5952bc7e732fdd95c001a07d428ef04.jpg"></p> <p>整个项目中最核心的部分就是 IQKeyboardManager 这个类,它负责 <strong>管理键盘出现或者隐藏时视图移动的距离</strong> ,是整个框架中最核心的部分。</p> <p>在这个框架中还有一些用于支持 IQKeyboardManager 的分类,以及显示在键盘上面的 IQToolBar:</p> <p><img src="https://simg.open-open.com/show/c5628b051daf870632f5b461bff7ef9d.png"></p> <p>使用红色标记的部分就是 IQToolBar ,左侧的按钮可以在不同的 UITextField 之间切换,中间的文字是 UITextField.placeholderText ,右边的 Done 应该就不需要解释了。</p> <p>这篇文章会主要分析 IQKeyboardManager 中解决的问题,会用小篇幅介绍包含占位符(Placeholder) IQTextView 的实现。</p> <h2>IQTextView 的实现</h2> <p>在具体研究如何解决键盘遮挡问题之前,我们先分析一下框架中最简单的一部分 IQTextView 是如何为 UITextView 添加占位符的。</p> <pre> <code class="language-objectivec">@interface IQTextView : UITextView @end </code></pre> <p>IQTextView 继承自 UITextView ,它只是在 UITextView 上添加上了一个 placeHolderLabel 。</p> <p>在初始化时,我们会为 UITextViewTextDidChangeNotification 注册通知:</p> <pre> <code class="language-objectivec">- (void)initialize { [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(refreshPlaceholder) name:UITextViewTextDidChangeNotification object:self]; } </code></pre> <p>在每次 UITextView 中的 text 更改时,就会调用 refreshPlaceholder 方法更新 placeHolderLabel 的 alpha 值来隐藏或者显示 label:</p> <pre> <code class="language-objectivec">-(void)refreshPlaceholder { if ([[self text] length]) { [placeHolderLabel setAlpha:0]; } else { [placeHolderLabel setAlpha:1]; } [self setNeedsLayout]; [self layoutIfNeeded]; } </code></pre> <h2>IQKeyboardManager</h2> <p>下面就会进入这篇文章的正题: IQKeyboardManager 。</p> <p>如果你对 iOS 开发比较熟悉,可能会发现每当一个类的名字中包含了 manager ,那么这个类可能可能遵循单例模式, IQKeyboardManager 也不例外。</p> <h3>IQKeyboardManager 的初始化</h3> <p>当 IQKeyboardManager 初始化的时候,它做了这么几件事情:</p> <ol> <li> <p>监听有关键盘的通知</p> <pre> <code class="language-objectivec">[[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]; </code></pre> </li> <li> <p>注册与 UITextField 以及 UITextView 有关的通知</p> <pre> <code class="language-objectivec">[self registerTextFieldViewClass:[UITextField class] didBeginEditingNotificationName:UITextFieldTextDidBeginEditingNotification didEndEditingNotificationName:UITextFieldTextDidEndEditingNotification]; [self registerTextFieldViewClass:[UITextView class] didBeginEditingNotificationName:UITextViewTextDidBeginEditingNotification didEndEditingNotificationName:UITextViewTextDidEndEditingNotification]; </code></pre> <ul> <li> <p>调用的方法将通知绑定到了 textFieldViewDidBeginEditing: 和 textFieldViewDidEndEditing: 方法上</p> <pre> <code class="language-objectivec">- (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]; } </code></pre> </li> </ul> </li> <li> <p>初始化一个 UITapGestureRecognizer ,在点击 UITextField 对应的 UIWindow 的时候,收起键盘</p> <pre> <code class="language-objectivec">strongSelf.tapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(tapRecognized:)]; - (void)tapRecognized:(UITapGestureRecognizer*)gesture { if (gesture.state == UIGestureRecognizerStateEnded) [self resignFirstResponder]; } </code></pre> </li> <li> <p>初始化一些默认属性,例如键盘距离、覆写键盘的样式等</p> <pre> <code class="language-objectivec">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]; </code></pre> </li> <li> <p>设置不需要解决键盘遮挡问题的类</p> <pre> <code class="language-objectivec">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]; </code></pre> </li> </ol> <p>整个初始化方法大约有几十行的代码,在这里就不再展示整个方法的全部代码了。</p> <h3>基于通知的解决方案</h3> <p>在这里,我们以 UITextField 为例,分析方法的调用流程。</p> <p>在初始化方法中,我们注册了很多的通知,包括键盘的出现和隐藏, UITextField 开始编辑与结束编辑。</p> <pre> <code class="language-objectivec">UIKeyboardWillShowNotification UIKeyboardWillHideNotification UIKeyboardDidHideNotification UITextFieldTextDidBeginEditingNotification UITextFieldTextDidEndEditingNotification </code></pre> <p>在这些通知响应时,会执行以下的方法:</p> <p>| Notification | Selector | |:-:|:-:| | UIKeyboardWillShowNotification | @selector(keyboardWillShow:) | | UIKeyboardWillHideNotification | @selector(keyboardWillHide:) | | UIKeyboardDidHideNotification | @selector(keyboardDidHide:) | | UITextFieldTextDidBeginEditingNotification | @selector(textFieldViewDidBeginEditing:) | | UITextFieldTextDidEndEditingNotification | @selector(textFieldViewDidEndEditing:) |</p> <p>整个解决方案其实都是基于 iOS 中的通知系统的;在事件发生时,调用对应的方法做出响应。</p> <h3>开启 Debug 模式</h3> <p>在阅读源代码的过程中,我发现 IQKeyboardManager 提供了 enableDebugging 这一属性,可以通过开启它,来追踪方法的调用,我们可以在 Demo 加入下面这行代码:</p> <pre> <code class="language-objectivec">[IQKeyboardManager sharedManager].enableDebugging = YES; </code></pre> <h2>键盘的出现</h2> <p>然后运行工程,在 Demo 中点击一个 UITextField</p> <p><img src="https://simg.open-open.com/show/a39faeb651945da9312392c75692f2f4.png"></p> <p>上面的操作会打印出如下所示的 Log:</p> <pre> <code class="language-objectivec">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 ****** </code></pre> <p>我们可以通过分析 - textFieldViewDidBeginEditing: 以及 - keyboardWillShow: 方法来了解这个项目的原理。</p> <h3>textFieldViewDidBeginEditing:</h3> <p>当 UITextField 被点击时,方法 - textFieldViewDidBeginEditing: 被调用,但是注意这里的方法并不是代理方法,它只是一个跟代理方法同名的方法,根据 Log,它做了三件事情:</p> <ul> <li>为 UITextField 添加 IQToolBar</li> <li>在调整 frame 前,保存当前 frame,以备之后键盘隐藏后的恢复</li> <li>调用 - adjustFrame 方法,将视图移动到合适的位置</li> </ul> <p>添加 ToolBar</p> <p>添加 ToolBar 是通过方法 - addToolbarIfRequired 实现的,在 - textFieldViewDidBeginEditing: 先通过 - privateIsEnableAutoToolbar 判断 ToolBar 是否需要添加,再使用相应方法 - addToolbarIfRequired 实现这一目的。</p> <p>这个方法会根据根视图上 UITextField 的数量执行对应的代码,下面为一般情况下执行的代码:</p> <pre> <code class="language-objectivec">- (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]; } } </code></pre> <p>在键盘上的 IQToolBar 一般由三部分组成:</p> <ul> <li>切换 UITextField 的箭头按钮</li> <li>指示当前 UITextField 的 placeholder</li> <li>Done Button</li> </ul> <p><img src="https://simg.open-open.com/show/c5628b051daf870632f5b461bff7ef9d.png"></p> <p>这些 item 都是 IQBarButtonItem 的子类</p> <p>这些 IQBarButtonItem 以及 IQToolBar 都是通过方法 - addPreviousNextDoneOnKeyboardWithTarget:previousAction:nextAction:doneAction: 或者类似方法添加的:</p> <pre> <code class="language-objectivec">- (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]; } </code></pre> <p>上面是方法简化后的实现代码,初始化需要的 IQBarButtonItem ,然后将这些 IQBarButtonItem 全部加入到 IQToolBar 上,最后设置 UITextField 的 accessoryView 。</p> <p>保存 frame</p> <p>这一步的主要目的是为了在键盘隐藏时恢复到原来的状态,其实现也非常简单:</p> <pre> <code class="language-objectivec">_rootViewController = [_textFieldView topMostController]; _topViewBeginRect = _rootViewController.view.frame; </code></pre> <p>获取 topMostController ,在 _topViewBeginRect 中保存 frame 。</p> <p>adjustFrame</p> <p>在上述的任务都完成之后,最后就需要调用 - adjustFrame 方法来调整当前根试图控制器的 frame 了:</p> <p>我们只会研究一般情况下的实现代码,因为这个方法大约有 400 行代码对不同情况下的实现有不同的路径,包括有 lastScrollView 、含有 superScrollView 等等。</p> <p>而这里会省略绝大多数情况下的实现代码。</p> <pre> <code class="language-objectivec">- (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]; } } } </code></pre> <p>方法 - adjustFrame 的工作分为两部分:</p> <ol> <li>计算 move 的距离</li> <li> <p>调用 - setRootViewFrame: 方法设置 rootView 的大小</p> <pre> <code class="language-objectivec">- (void)setRootViewFrame:(CGRect)frame { UIViewController *controller = [_textFieldView topMostController]; frame.size = controller.view.frame.size; </code></pre> <pre> <code class="language-objectivec">[UIView animateWithDuration:_animationDuration delay:0 options:(_animationCurve|UIViewAnimationOptionBeginFromCurrentState) animations:^{ [controller.view setFrame:frame]; } completion:NULL]; </code></pre> }</li> </ol> <p>不过,在 - textFieldViewDidBeginEditing: 的调用栈中,并没有执行 - setRootViewFrame: 来更新视图的大小,因为点击最上面的 UITextField 时,不需要移动视图就能保证键盘不会遮挡 UITextField 。</p> <h3>keyboardWillShow:</h3> <p>上面的代码都是在键盘出现之前执行的,而这里的 - keyboardWillShow: 方法的目的是为了保证键盘出现之后,依然没有阻挡 UITextField 。</p> <p>因为每一个 UITextField 对应的键盘大小可能不同,所以,这里通过检测键盘大小是否改变,来决定是否调用 - adjustFrame 方法更新视图的大小。</p> <pre> <code class="language-objectivec">- (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]; } } </code></pre> <p>在 - adjustFrame 方法调用之前,执行了很多代码都是用来保存一些关键信息的,比如通知对象、动画曲线、动画时间。</p> <p>最关键的是更新键盘的大小,然后比较键盘的大小 CGSizeEqualToSize(_kbSize, oldKBSize) 来判断是否执行 - adjustFrame 方法。</p> <p>因为 - adjustFrame 方法的结果是依赖于键盘大小的,所以这里对 - adjustFrame 是有意义并且必要的。</p> <h2>键盘的隐藏</h2> <p>通过点击 IQToolBar 上面的 done 按钮,键盘就会隐藏:</p> <p><img src="https://simg.open-open.com/show/4fa67026095650839606322c1d5b4c23.png"></p> <p>键盘隐藏的过程中会依次调用下面的三个方法:</p> <ul> <li>- keyboardWillHide:</li> <li>- textFieldViewDidEndEditing:</li> <li>- keyboardDidHide:</li> </ul> <pre> <code class="language-objectivec">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 ****** </code></pre> <p>键盘在收起时,需要将视图恢复至原来的位置,而这也就是 - keyboardWillHide: 方法要完成的事情:</p> <pre> <code class="language-objectivec">[strongSelf.rootViewController.view setFrame:strongSelf.topViewBeginRect] </code></pre> <p>并不会给出该方法的全部代码,只会给出关键代码梳理它的工作流程。</p> <p>在重新设置视图的大小以及位置之后,会对之前保存的属性进行清理:</p> <pre> <code class="language-objectivec">_lastScrollView = nil; _kbSize = CGSizeZero; _startingContentInsets = UIEdgeInsetsZero; _startingScrollIndicatorInsets = UIEdgeInsetsZero; _startingContentOffset = CGPointZero; </code></pre> <p>而之后调用的两个方法 - textFieldViewDidEndEditing: 以及 - keyboardDidHide: 也只做了很多简单的清理工作,包括添加到 window 上的手势,并重置保存的 UITextField 和视图的大小。</p> <pre> <code class="language-objectivec">- (void)textFieldViewDidEndEditing:(NSNotification*)notification{ [_textFieldView.window removeGestureRecognizer:_tapGesture]; _textFieldView = nil; } - (void)keyboardDidHide:(NSNotification*)aNotification { _topViewBeginRect = CGRectZero; } </code></pre> <h2>UITextField 和 UITextView 通知机制</h2> <p>因为框架的功能是基于通知实现的,所以通知的时序至关重要,在 IQKeyboardManagerConstants.h 文件中详细地描述了在编辑 UITextField 的过程中,通知触发的先后顺序。</p> <p><img src="https://simg.open-open.com/show/f0eb1e28cded7ce75356b67c4de93d4d.png"></p> <p>上图准确说明了通知发出的时机,透明度为 50% 的部分表示该框架没有监听这个通知。</p> <p>而 UITextView 的通知机制与 UITextField 略有不同:</p> <p><img src="https://simg.open-open.com/show/d54179cdee871c08a98240ac0ca55ec2.png"></p> <p>当 Begin Editing 这个事件发生时, UITextView 的通知机制会先发出 UIKeyboardWillShowNotification 通知,而 UITextField 会先发出 UITextFieldTextDidBeginEditingNotification 通知。</p> <p>而这两个通知的方法都调用了 - adjustFrame 方法来更新视图的大小,最开始我并不清楚到底是为什么?直到我给作者发了一封邮件,作者告诉我这么做的原因:</p> <p>Good questions draveness. I'm very happy to answer your questions. There is a file in library IQKeyboardManagerConstants.h. You can find iOS Notification mechanism structure.</p> <p>You'll find that for UITextField, textField notification gets fire first and then UIKeyboard notification fires.</p> <p>For UITextView, UIKeyboard notification gets fire first and then UITextView notification get's fire.</p> <p>So that's why I have to call adjustFrame at both places to fulfill both situations. But now I think I should add some validation and make sure to call it once to improve performance.</p> <p>Let me know if you have some more questions, I would love to answer them. Thanks again to remind me about this issue.</p> <p>在不同方法中调用通知的原因是,UITextView 和 UITextField 通知机制的不同,不过作者可能会在未来的版本中修复这一问题,来获得性能上的提升。</p> <h2>小结</h2> <p>IQKeyboardManager 使用通知机制来解决键盘遮挡输入框的问题,因为使用了分类并且在 IQKeyboardManager 的 + load 方法中激活了框架的使用,所以达到了零行代码解决这一问题的效果。</p> <p>虽然 IQKeyboardManager 很好地解决了这一问题、为我们带来了良好的体验。不过,由于其涉及 UI 层级;并且需要考虑非常多的边界以及特殊条件,框架的代码不是很容易阅读,但是这不妨碍 IQKeyboardManager 成为非常优秀的开源项目。</p> <p><a href="/misc/goto?guid=4959675339203299344">阅读原文</a></p>