处理键盘通知

自从 iOS 8 引入了第三方键盘扩展后(或更早),键盘通知就不太正常了。例如,若用户使用中文拼音键盘,弹出时UIKeyboardWillShowNotification可能发送不只一次(有可能两次,甚至三次)。

作者:@nixzhu


近日在 iOS 9 beta4 上,我更观察到 UIKeyboardWillShowNotification 可能会少发,导致之前根据其发送次数做的算法不能正常工作,结果就是在使用中文拼音键盘时,本该处于键盘上方的输入框会被键盘挡住大部分。

为了解决这个问题,同时也对键盘通知相关的代码做整理并重构(毕竟这些代码分散在 ViewController 里也不好维护,更难以重用),我想写一个单独的库是最好的选择。至于库的名字,“键盘侠”就很不错。虽然在中文里它不算个好词汇,不过英文念着还不错:KeyboardMan。

先说一下之前对键盘通知UIKeyboardWillShowNotification发送多次的处理。

在做键盘跟随动画时,我们需要根据键盘的高度来调整某些 View 的位置,或者要更新 UIScrollView(UITableView、UICollectionView)的 contentOffsetcontentInset,以使某些内容不被键盘挡住。

既然是键盘跟随动画,那必然要监听UIKeyboardWillShowNotification以获取键盘高度以及动画参数(时长和曲线类型)。因为当用户使用某些键盘时,UIKeyboardWillShowNotification并不止发送一次,第一次的高度并不是最后完整键盘的高度。如果我们简单地独立对待每一次通知,但由于调整contentOffset应该用增量的方式,将导致我们要在处理键盘通知前纪录当前的contentOffset并利用它实现增量的效果。很明显,“键盘弹出前的contentOffset”需要我们的小心维护,自然,这并不有趣。

然后,上面的方式可能失效,例如当UIKeyboardWillShowNotification本该发送两次时却只发送了一次,那我们就不能获取到正确的键盘高度,以此,即不能正确设置contentOffset,也会导致键盘上的输入框会被键盘挡住(输入框的位置调整不需要考虑增量的问题,只需要正确的键盘高度)。

虽说UIKeyboardWillShowNotification的发送次数不够很可能是 iOS 9 beta4 的 bug,但我们很难保证这样的 bug 不会在之后的正式版中出现。因此,我们还需要更好的办法。

键盘通知除了我们常见的四个:

  1. let UIKeyboardWillShowNotification: String
  2. let UIKeyboardDidShowNotification: String
  3. let UIKeyboardWillHideNotification: String
  4. let UIKeyboardDidHideNotification: String

之外,还有两个 iOS 5 才引入的:

  1. let UIKeyboardWillChangeFrameNotification: String
  2. let UIKeyboardDidChangeFrameNotification: String

经我测试,在UIKeyboardWillShowNotification发送次数不正确时,UIKeyboardWillChangeFrameNotificationUIKeyboardDidChangeFrameNotification都能正确发送。这自然会成为解决问题的关键。

因为我们要做的是键盘跟随动画,因此不考虑UIKeyboardDidChangeFrameNotification,因为Did表明它“滞后”了。那么UIKeyboardWillChangeFrameNotification就成为了我们唯一的希望。

通过监听它,我们可以观察到它会在UIKeyboardWillShowNotification之前或者在UIKeyboardDidHideNotification之后发出。因为键盘隐藏的通知并没有不正常,所以我们不需要关心其在UIKeyboardDidHideNotification的发送。也就是说,我们要把UIKeyboardWillChangeFrameNotification当作UIKeyboardWillShowNotification来用,以保证获取到正确的键盘高度。但这样以来,键盘出现通知的“次数”就多了,我们还要想办法缩减到正确的次数。

我们先把键盘通知分成两类:Show 和 Hide。因为UIKeyboardWillChangeFrameNotification会被当作 Show 来来使用,需要避免它在 Hide 时生效。

于是我们定义一个结构 KeyboardInfo:

  1. public struct KeyboardInfo {
  2. public let animationDuration: NSTimeInterval
  3. public let animationCurve: UInt
  4. public let frameBegin: CGRect
  5. public let frameEnd: CGRect
  6. public var height: CGFloat {
  7. return frameEnd.height
  8. }
  9. public let heightIncrement: CGFloat
  10. public enum Action {
  11. case Show
  12. case Hide
  13. }
  14. public let action: Action
  15. let isSameAction: Bool
  16. }

并定义一个变量:

  1. var keyboardInfo: KeyboardInfo?

每次收到键盘通知时,我们就更新此变量,其中action能表示当前是 Show 还是 Hide,而isSameAction需要计算,表示当前的action是否与之前的一样,可用于区别键盘通知类型的转换。

那么我们的通知处理逻辑如下:

  1. func keyboardWillShow(notification: NSNotification) {
  2. handleKeyboard(notification, .Show)
  3. }
  4. func keyboardWillChangeFrame(notification: NSNotification) {
  5. if let keyboardInfo = keyboardInfo {
  6. if keyboardInfo.action == .Show {
  7. handleKeyboard(notification, .Show)
  8. }
  9. }
  10. }
  11. func keyboardWillHide(notification: NSNotification) {
  12. handleKeyboard(notification, .Hide)
  13. }
  14. func keyboardDidHide(notification: NSNotification) {
  15. keyboardInfo = nil
  16. }

其中,私有函数handleKeyboard(_, _) 将通知里的信息取出生成 KeyboardInfo 并赋值给keyboardInfo

然后注意keyboardWillChangeFrame函数,它处理UIKeyboardWillChangeFrameNotification。因为此通知会在UIKeyboardWillShowNotification之前发送,要将它当作UIKeyboardWillShowNotification来用的前提是:

  1. keyboardInfo 不存在,表示键盘还未弹出过,(因为 UIKeyboardWillShowNotification 至少会发送一次,故不处理 UIKeyboardWillChangeFrameNotification
  2. keyboardInfo已存在,只要保证前一次是 Show 再处理即可。

最后,键盘通知的次数处理,在设置 keyboardInfo 时,我们增加一个 willSet

  1. var keyboardInfo: KeyboardInfo? {
  2. willSet {
  3. if let info = newValue {
  4. if !info.isSameAction || info.heightIncrement != 0 {
  5. //TODO
  6. }
  7. }
  8. }
  9. }

可以看出,我们只会在键盘Action改变时,或键盘高度增量不等于 0 时才进行真正的处理。由此,就可以避免因为将UIKeyboardWillChangeFrameNotification当作UIKeyboardWillShowNotification用而导致“次数”反而增加了。

不过还有一个新情况:当键盘出现后,若用户按下 Home 进入后台,然后回到本应用,那么 iOS 还会再发送UIKeyboardWillShowNotificationUIKeyboardWillChangeFrameNotification,而我们并不需要它们。好在这样的情况很好处理,只需在 willSet 的顶部先判断一下应用的状态即可:

  1. var keyboardInfo: KeyboardInfo? {
  2. willSet {
  3. if UIApplication.sharedApplication().applicationState != .Active {
  4. return
  5. }
  6. if let info = newValue {
  7. if !info.isSameAction || info.heightIncrement != 0 {
  8. //TODO
  9. }
  10. }
  11. }
  12. }

此外,iOS 的键盘在某些设备上还可以拆分(Split)和浮动(Undock),这时系统会发送两个 Hide 通知,若之后再 dismiss 时,系统会发送 UIKeyboardWillChangeFrameNotification,不过这个时候就不能将其当做 Show 来处理了。好在上面的keyboardWillChangeFrame函数已经避免了这样的情况。

有了这些代码和考量后,我们就可以暴露“闭包”给外部,闭包的执行就放在上面代码 TODO 的位置。

出于方便的考虑,KeyboardMan 共暴露三个闭包:

  1. public var animateWhenKeyboardAppear: ((appearPostIndex: Int, keyboardHeight: CGFloat, keyboardHeightIncrement: CGFloat) -> Void)?
  2. public var animateWhenKeyboardDisappear: ((keyboardHeight: CGFloat) -> Void)?
  3. public var postKeyboardInfo: ((keyboardMan: KeyboardMan, keyboardInfo: KeyboardInfo) -> Void)?

其中前两个闭包比较方便,放在其中的代码会被自动“动画”,易于使用。第三个将每次刷新的 KeyboardInfo 发送出去,使用的逻辑就交给程序员了。另外,稍微注意一下 animateWhenKeyboardAppear 闭包的appearPostIndex参数,它表示“本次”键盘出现时,通知发送到第几次了(每次都从0开始,有可能你的代码里用得到)。如果你用 postKeyboardInfo 闭包那么可用keyboardMan参数取到它。

还有一些细节,包括通知监听开启或关闭的实现(注意 deinit 里设置属性并不会触发对应的 willSet 或 didSet),通知内容解析的实现,具体请看 KeyboardMan 的代码。另外,Demo 里有三个闭包的基本用法。

项目地址:https://github.com/nixzhu/KeyboardMan

最后是个预告,我最近在写一本关于算法的书(代码用 Swift 2),不会是系统的算法讲解,而是从具体例子实现一些“综合性”的算法,重点在于分析的过程。但只刚开了个头,希望能在 Swift 2 正式版发布前完成,似乎时间不多了,不敢保证。


欢迎转载,但请一定注明出处! https://github.com/nixzhu/dev-blog