优化输入体验的关键:keyboard 技巧全介绍

这一节主要会讲如何构建更好的输入体验:

  • 将键盘融入你的布局
  • 使用 Input Accessory View
  • 让你的 App 更好地应对多种语言输入
  • 使用 traits 让文字补齐更加智能
  • 支持实体键盘
  • 创建自定义的输入控件
  • Keyboard Extension 的一些建议和实践经验

让键盘融入你的 App

动态适应键盘

键盘的高度会跟随语言和设置变化,例如英文的键盘跟中文的九宫格键盘高度不同,第三方键盘的高度由于没有限制,所以任何高度都有可能。那我们该如何获取到键盘的高度呢,系统提供了六个通知:

  • UIKeyboardWillShow 键盘即将出现
  • UIKeyboardDidShow 键盘已出现
  • UIKeyboardWillHide 键盘即将隐藏
  • UIKeyboardDidHide 键盘已隐藏
  • UIKeyboardDidChangeFrame 键盘的 frame 即将改变
  • UIKeyboardDidChangeFrame 键盘的 frame 已改变

通过接收这些通知,我们可以获取到键盘 frame 修改前和修改后的值,但在深入探讨之前,让我们先来了解一种特殊状况。

在 iPad 里,用户可以随意调整键盘的位置,甚至是分成两个键盘,这个时候你不会想让键盘阻挡到 App 的内容,了解以下几点可以帮助你避免这样的情况发生。

  • 隐藏键盘,或者是移动键盘位置都会发送 Hide 通知。
  • Frame 修改的通知会在键盘位置移动后继续发送。
  • 一般情况下,只要追踪最近一次发出的 HideShow 通知就可以了。

在了解完这个特例之后,让我们继续回到关于 Keyboard 的通知。

要记得键盘的 frame 总是以整个屏幕为参考系的,记得要把 frame 转化为我们使用的坐标系,然后获取我们的 view 和键盘的交集:

@objc func keyboardFrameChanged(_ notification: Notification) -> Void { 
    if !keyboardIsHidden {      
    guard let userInfo = notification.userInfo,
            let frame = userInfo[UIKeyboardFrameEndUserInfoKey] as? CGRect 
        else { 
            return 
        }
   let convertedFrame = view.convert(frame, from: UIScreen.main.coordinateSpace)    
   let intersectedKeyboardHeight = view.frame.intersection(convertedFrame).height 
}}

不可滚动的布局

有时候我们需要跟不能滚动的布局打交道,例如登录页面的登录按钮,键盘出现的时候,我们希望它不被键盘挡到。

在这里我们有五个 textField,使用 layoutGuide 来控制它们之间的间隔,切换键盘的时候键盘就可以自动调整它们的间距,让五个 textField 全部显示出来,这是怎么做到的?

注:这个页面布局的基本思路是,在 textField 之间添加空白的 view,然后将这些 view 使用 AutoLayout 指定为相同高度,最后让最下面的 view 的 bottom 对齐键盘的 top 就可以了。
iOS 9 之后可以使用 layoutGuide 取代这些占位的 view,进一步提高性能。

首先我们创建一个自定义的 keyboardGuide,用来跟踪键盘高度的变化,然后给 keyboardGuide 一个高度的约束 heightConstraint,以便我们接下来再进行调整:

func setUpViews() {  
    // ... view set up ...  
    let keyboardGuide = UILayoutGuide()  
    view.addLayoutGuide(keyboardGuide) 
    heightConstraint = keyboardGuide.heightAnchor.constraint(equalToConstant:           kDefaultHeight) 
    heightConstraint.isActive = true  
    // ... view set up ...
}

接着我们把 keyboardGuide 跟 view 的 safeAreaLayoutGuide 绑定起来,然后将最下面的 layoutGuide(在这里是 bottomSpacer)跟 keyboardGuide 绑定起来:

func setUpViews() { 
    // ...  
    keyboardGuide.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor).isActive = true    bottomSpacer.bottomAnchor.constraint(equalTo: keyboardGuide.topAnchor).isActive = true 
    // ...
 }

最后,在接收到通知后,把 heightConstraint 的数值改成键盘遮挡的高度:

@objc func keyboardFrameChanged(_ notification: Notification) -> Void { 
    if !keyboardIsHidden {      
    // ... frame 坐标系转化 ...      
    UIView.animate(withDuration: 0.2) { 
            heightConstraint.constant = intersectedKeyboardHeight 
            view.layoutIfNeeded()     
            } 
    }
 }

可滚动的布局

平时更常见到的是可滚动的布局,为了让 ScrollView 里面的内容一直保持可见,我们需要调节 ScrollView 的 contentInset,跟之前做的很类似,只是稍微复杂了一点:

  1. 保证键盘可见
  2. frame 坐标系的转化
  3. 给 contentInsets 设置一个合适的值
  4. 如果有需要的话对于 ScrollView 的内容做处理
@objc func keyboardFrameChanged(_ notification: Notification) -> Void { 
    if !keyboardIsHidden {      
    // ... frame 坐标系转化 ...  
    scrollView.contentInset.bottom = intersectedKeyboardHeight
    // ... 处理 ScrollView 的内容 ...  
    } 
}

如果使用 TableView 的话,那就更简单了,只要直接滚动到相应的 Row 就行了。

@objc func keyboardFrameChanged(_ notification: Notification) -> Void { 
    let bottomRow = IndexPath(row: items.count - 1, section: 0)             
    tableView.scrollToRow(at: bottomRow, at: .bottom, animated: true)
}

添加 Accessory View

现在让我们来聊一下如果扩展输入框,添加一个 Accessory View,什么是 Accessory View?它是一个键盘上方的一个 view,在这里是 textField + button,也可以是一个 toolBar,还可以是图片,任何你想放上去的东西都可以,因为它就是一个普通的 view。

添加的方式很简单,重写 UIViewControllercanBecomeFirstResponder,返回 true,并且重写 UIViewinputAccessoryView 或者 UIViewControllerinputAccessoryViewController

让 Accessory View 拥有动态高度

接着我们再来聊聊更多人在意的一个话题,动态高度的 Accessory View,例如 IM 软件里的输入框,我们希望可以根据输入的文本长度,把输入框撑开,以便让我们看到输入的整体内容。

如果用的是 UITextView 的话,可以通过设置 textContainer 的 heightTracksTextView 属性,并且禁止 textView 的滚动来实现。

expandingTextView.textContainer.heightTracksTextView = trueexpandingTextView.isScrollEnabled = false

然后重写 instrinsicContentSize,计算内容高度,设置最小和最大高度,让内部的 textView 可以根据文本长度把我们自定义的 accessoryView 高度撑开。

注:instrinsicContentSize 是 AutoLayout 系统用来确定控件内容大小的一个属性,如果没有对尺寸有约束的话,就会直接使用 instrinsicContentSize 作为控件大小。

// 使用 intrinsicContentSize 来决定高度
override var intrinsicContentSize: CGSize {   var newSize = self.bounds.size   newSize.height = minHeight

   if expandingTextView.bounds.size.height > 0 {      newSize.height = expandingTextView.bounds.size.height + verticalPadding   }
      if newSize.height > maxHeight {      newSize.height = maxHeight   }
      return newSize}

使用上下文优化输入体验

让你的 App 更好地支持多语言输入

如果大家有外国友人或者是在国外生活的话,应该会遇到多语言输入的问题,跟家里的爸妈聊天的时候需要用中文输入法,而跟身边的同事聊天的时候会用到英文输入法,我们需要不停地来回切换键盘。

如果 App 能够知道每一段对话使用的输入法,或者说记录下来的话,输入体验就会好很多,实际上 iMessage 很早就做到了这一点,这是怎么做到的呢?


我们通过一个标识符,将输入法和输入控件关联到一起,这个标识符就是 UIResponder 的属性 textInputContextIdentifier

当 textView 成为第一响应者时,系统就会去 UserDefaults 里使用 textInputContextIdentifier 查找有没有相应的输入法记录,有的话就会使用。

而每次键盘弹起或者切换的时候,系统也会在 UserDefaults 里以 textInputContextIdentifier 为 key 去记录这次使用的输入法。

听起来有点复杂,但要做的事情很简单,设置一个合理的 textInputContextIdentifier 就可以了,例如当前对话的用户的 id,当前对话群组的 id,都可以。

但我们该给哪一个 responder 设置 textInputContextIdentifier 呢?

每次键盘升起之前,我们自定义的 Accessory View 会成为 firstResponder,接着是 View Controller,Navigation Controller...

系统会沿着响应链去查找 textInputContextIdentifier,找到之后立刻唤起相应的输入法。

top Created with Sketch.