WWDC 2017 - 优化输入体验的关键:keyboard技巧全介绍
视频: WWDC 2017 - Session 242 - The Keys to a Better Text Input Experience
原文: 优化输入体验的关键:keyboard 技巧全介绍
作者: kemchenj
这一节主要会讲如何构建更好的输入体验:
- 将键盘融入你的布局
- 使用 Input Accessory View
- 让你的 App 更好地应对多种语言输入
- 使用 traits 让文字补齐更加智能
- 支持实体键盘
- 创建自定义的输入控件
- Keyboard Extension 的一些建议和实践经验
让键盘融入你的 App
动态适应键盘
键盘的高度会跟随语言和设置变化,例如英文的键盘跟中文的九宫格键盘高度不同,第三方键盘的高度由于没有限制,所以任何高度都有可能。那我们该如何获取到键盘的高度呢,系统提供了六个通知:
UIKeyboardWillShow
键盘即将出现UIKeyboardDidShow
键盘已出现UIKeyboardWillHide
键盘即将隐藏UIKeyboardDidHide
键盘已隐藏UIKeyboardDidChangeFrame
键盘的 frame 即将改变UIKeyboardDidChangeFrame
键盘的 frame 已改变
通过接收这些通知,我们可以获取到键盘 frame 修改前和修改后的值,但在深入探讨之前,让我们先来了解一种特殊状况。
在 iPad 里,用户可以随意调整键盘的位置,甚至是分成两个键盘,这个时候你不会想让键盘阻挡到 App 的内容,了解以下几点可以帮助你避免这样的情况发生。
- 隐藏键盘,或者是移动键盘位置都会发送
Hide
通知。 - Frame 修改的通知会在键盘位置移动后继续发送。
- 一般情况下,只要追踪最近一次发出的
Hide
和Show
通知就可以了。
在了解完这个特例之后,让我们继续回到关于 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,跟之前做的很类似,只是稍微复杂了一点:
- 保证键盘可见
- frame 坐标系的转化
- 给 contentInsets 设置一个合适的值
- 如果有需要的话对于 ScrollView 的内容做处理
@objc func keyboardFrameChanged(_ notification: Notification) -> Void { |
如果使用 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。
添加的方式很简单,重写 UIViewController
的 canBecomeFirstResponder
,返回 true
,并且重写 UIView
的 inputAccessoryView
或者 UIViewController
的 inputAccessoryViewController
。
让 Accessory View 拥有动态高度
接着我们再来聊聊更多人在意的一个话题,动态高度的 Accessory View,例如 IM 软件里的输入框,我们希望可以根据输入的文本长度,把输入框撑开,以便让我们看到输入的整体内容。
如果用的是 UITextView 的话,可以通过设置 textContainer 的 heightTracksTextView
属性,并且禁止 textView 的滚动来实现。expandingTextView.textContainer.heightTracksTextView = trueexpandingTextView.isScrollEnabled = false
然后重写 instrinsicContentSize
,计算内容高度,设置最小和最大高度,让内部的 textView 可以根据文本长度把我们自定义的 accessoryView 高度撑开。
注:
instrinsicContentSize
是 AutoLayout 系统用来确定控件内容大小的一个属性,如果没有对尺寸有约束的话,就会直接使用instrinsicContentSize
作为控件大小。
// 使用 intrinsicContentSize 来决定高度 |
使用上下文优化输入体验
让你的 App 更好地支持多语言输入
如果大家有外国友人或者是在国外生活的话,应该会遇到多语言输入的问题,跟家里的爸妈聊天的时候需要用中文输入法,而跟身边的同事聊天的时候会用到英文输入法,我们需要不停地来回切换键盘。
如果 App 能够知道每一段对话使用的输入法,或者说记录下来的话,输入体验就会好很多,实际上 iMessage 很早就做到了这一点,这是怎么做到的呢?
我们通过一个标识符,将输入法和输入控件关联到一起,这个标识符就是 UIResponder 的属性 textInputContextIdentifier
。
当 textView 成为第一响应者时,系统就会去 UserDefaults 里使用 textInputContextIdentifier
查找有没有相应的输入法记录,有的话就会使用。
而每次键盘弹起或者切换的时候,系统也会在 UserDefaults 里以 textInputContextIdentifier
为 key 去记录这次使用的输入法。
听起来有点复杂,但要做的事情很简单,设置一个合理的 textInputContextIdentifier
就可以了,例如当前对话的用户的 id,当前对话群组的 id,都可以。
但我们该给哪一个 responder 设置 textInputContextIdentifier
呢?
每次键盘升起之前,我们自定义的 Accessory View 会成为 firstResponder,接着是 View Controller,Navigation Controller…
系统会沿着响应链去查找 textInputContextIdentifier
,找到之后立刻唤起相应的输入法。
在这里我们把输入逻辑交给 ConversationViewController 处理:class ConversationViewController: UITableViewController, UITextViewDelegate {
// ... other code ...
override var textInputContextIdentifier: String? {
// 返回一个唯一的标识符
// 以便系统记录用户最近一次,在这段对话里使用的输入法 // 这个标识符可以是任意的东西,例如这段对话的 id return self.conversation?.id }
// ... other code ...}
简单的一段代码就完成了输入法的自动记忆,重点是找到一个合适的 responder 设置一个合理的标识符。
如果你不止想要记忆输入法,还想为某个特定的语言提供自动纠正之类的功能的话,可以通过 textInputMode
去获取输入法使用的语言。
更加注重上下文
在 iOS 4 的时候引入了 UIKeyboardType,让开发者可以根据上下文,在 App 里输入时弹出合适的键盘,去年我们又更近了一步,引入了 UITextContentTypes 让开发者可以提供上下文给系统,让系统可以更好地完成输入预测,自动纠正等等功能。
用户通过这些优化,甚至不需要打字,只需要点击 QuickType Bar 上系统提供的预测结果,就可以完成输入操作。
输入地址之前,如果我们打开原生的地图应用,搜索过某个地址的话,还可以在 QuickType Bar 看到这个地址,这是通过系统的 NSUserActivity 实现的,更多内容可以查看 WWWDC 2016 Session 240 Increasing Usage of Your App With Proactive Suggestion。
今年 iOS 11 还给 UITextContentType 新增了两个预设值,让用户可以在 App 里直接使用 Safari 里记录的登录信息,自动填充用户名和密码,更多内容可以查看本书另一篇文章《iOS 11 里 App 终于可以密码自动填充了》。
更智能的输入
更智能的引号和破折号
今年 iOS 会自动将双引号转化为符合语境的双引号,连续的两个 -
会转化为短破折号,三个会转化为长破折号。
智能插入和删除
在手机上输入英文的时候,插入和删除永远都很烦人,除了增/删单词之后,你还总是需要重新移动光标去再处理单词前后的空格,今年 iOS 将会自动为用户处理前后空格的问题。.default
.yes
.no
另外,以上提到的这些智能输入功能,默认都会开启(.default
),但开发者也可以手动关闭一部分,例如输入快递单号的时候,就不需要自动纠正功能。我们只要通过 UITextInputTraits
去设置即可。
标记文本
提到文本输入,就不得不说标记文本,例如汉字,日文等等,总是需要先输入音符或者部首之类的信息,然后再从备选列表把要输入的字符挑选出来。
在 spotlight 里面,可以看到用户只输入了拼音,甚至都不需要选择具体的文字,搜索结果已经呈现了出来。
实现的原理也很简单,在用户还没有选择字符的情况下,我们可以通过 UITextInput 获取到系统预测的结果,提前进行搜索并呈现结果。
更多关于多语言输入的内容,可以查看 WWDC 2016 Session Internationalization Best Practices。
实体键盘输入优化
大家都会使用实体键盘,跟软键盘比起来,很大的一个区别就是快捷键,我们最常用到的可能是 cmd + c
, cmd + v
之类的操作,iOS 今年也加入了快捷键的功能。
iPad 的 App 可以定义一套专用的快捷键,直接重写 keyCommands
属性就可以了:UIKeyCommandclass ConversationViewController: UITableViewController, UITextViewDelegate {
// ... some code ...
override var keyCommands: [UIKeyCommand]? {
return [
// Command + 下箭头,切换到下一个对话
UIKeyCommand(input: UIKeyInputDownArrow,
modifierFlags: .command,
action: #selector(switchToConversationKeyCommandInvoked(sender:)), discoverabilityTitle: NSLocalizedString("GO_TO_NEXT_CONVERSATION", comment: "")), // Command + 上箭头,切换到上一个对话
UIKeyCommand(input: UIKeyInputUpArrow,
modifierFlags: .command,
action: #selector(switchToConversationKeyCommandInvoked(sender:)), discoverabilityTitle: NSLocalizedString("GO_TO_PREV_CONVERSATION", comment: "")) ]
}
//... some code ...}
UIKeyCommand 的定义很简单,主键,辅助键(command,shift之类的),触发的动作,快捷键的名称。
定义好之后,我们就可以在快捷键提示的界面看到我们定义的快捷键了。
自定义输入控件
前面我们提到了 UIKeyboardType
,让开发者可以选择键盘的类型,但这些原生的键盘类型也许满足不了你的输入需求,例如计算器需要各种数学符号,Swift Playground 需要自动补齐,表格软件里需要公式等等,这个时候我们就需要自定义键盘了。
为我们的宠物自定义一个 Input View
这里我们以一个宠物专用的 Input View 为例,讲解一下如何自定义 Input View,首先我们的宠物需要使用特定的语言,并且能表达的词汇很有限。
我们可以看到自定义的 Input View 有三个字符可以输入,喂食,散步和暂停,像普通的键盘一样也是会出现在屏幕下方,一样有 Accessory View。
自定义 Input View 最简单的方式就是,继承 UIInputViewController
新建一个类,这样做有很多好处,例如跟系统键盘一样的灰色背景等等,更重要的是提供了一个 textDocumentProxy
去跟当前用户选择的 textField 进行交互,获得 textContentType
,inputTraits
之类的上下文信息,textDocumentProxy
也可以往 textField 里插入文本,获取到插入点的位置,插入点前后的文本。class ConversationViewController: UITableViewController, UITextViewDelegate {
private let customInputView = AnimalInputView()
override var canBecomeFirstResponder: Bool {
return true
}
override var inputView: UIInputView? {
// 返回我们自定义的 InputView 实例
return customInputView
}
// ... 其它代码 ...}
回到我们的宠物聊天软件里,ConversationViewController
首先需要重写 canBecomeFirstResponder
属性,让自己成为响应链的一环,接着重写 inputView
属性(或者 inputViewController
),返回我们自定义的 inputView 就可以了。
Keyboard Extension
在 iOS 8 里引入了 Keyboard Extension,实际上把我们刚刚自定义的 inputView 转化为 Keyboard Extension 是一件非常简单的事情,这样就可以让用户在别的 App 里也能使用我们自定义的 inputView 了。
首先我们需要在项目里新建一个 Target,选择 Custom Keyboard Extension,系统会自动为我们创建一个 KeyboardViewController,直接把 CustomInputView 的逻辑搬到这里就可以了。
或者是复用我们在项目里定义好的 AnimalInputViewController 类型,像上图一样打开 AnimalInputViewController 文件,然后在右边的 Target Membership 里把刚刚新增的 Target 打上勾,让 AnimalInputViewController 也加入 AnimalKeyboard 的编译(直接在 Build Phase 里加入也可以)。
然后打开 AnimalKeyboard 目录下的 info.plist,如图将 NSExtensionPrincipalClass 改为 ${PRODUCT_MODULE_NAME}.AnimalInputViewController
(选择具体使用的 inputViewController 类型)。
最后,我们只要进入系统设置 -> PetChat(根据你的 App 名字不同而改变)**->** Keyboards -> AnimalKeyboard(Keyboard Extension 的 target 名称),将键盘开关打开,然后在别的 App 里也可以使用自定义的键盘了。
iOS 11 新增的 API
- 选中的文字。现在的
inputViewController
可以通过selectedText
获取到当前选中的文字。 - documentIdentifier。
documentIdentifier
的作用是来区分开每一个 textField,例如当用户切换 textField 时你可以把输入预测给清空。 - 请求获取最高访问的权限。现在可以跟用户发起请求,要求获取最高访问权限,以便创建更好的智能输入体验,后面会更详细地介绍最高访问权限的内容。
第三方键盘 API
- 可以直接使用系统的输入法列表菜单。而不是像之前那样只能切换到下个输入法。
- 个性化输入预测补齐。这里的个性化是指,你可以获取到用户的通讯录资料,在优化输入预测时非常有用。
- 多语言支持。前面提过的
textInputContextIdentifier
,如果你的输入法也支持多语言的话,就可以通过这个去优化多语言输入的体验。
设计用户权限系统
- 隐私。iOS 系统拥有一套隐私权限系统,让用户可以控制 App 可获取的隐私数据。* 使用用户信息。例如将用户信息发送到云端进行分析,一定要保证有一套用户协议机制,在用户统一的情况下才可以使用用户的使用信息。
- 最高访问权限的请求。Keyboard Extension 可以通过向用户请求最高访问权限,来获取更多用户信息以优化输入体验。
最高访问权限
这件事情的价值不在于最高访问权限的获取,很多功能都是原生支持的,不一定需要这么高的权限,权限的获取只是对于现有功能的补充,而不是必要的。
其中一个需要获取访问权限的场景是,keyboard 需要跟主 App 交互,例如访问数据库里的热门词条。或者是网络的请求,当前的地点,通讯录等等,需要注意的是通讯录本来就可以获取,只是在获取一些额外信息的时候需要申请权限,例如联系人备注之类的。
总结
- 如何让键盘更好地与 App 进行交互
- 动态适应键盘高度,调整 App 布局
- 使用 Accessory View 来对于键盘进行功能补充
- 使用高级的智能输入功能,去优化用户体验
- 通过
textInputContextIdentifier
优化多语言输入 - 使用
textContentType
让系统提供登录信息自动补齐的功能 - 使用
markedText
获取输入结果预测 - 为实体键盘创建快捷键
- 通过
- 构建一个 Keyboard Extension 比你想象的更简单
- 自定义 inputView/inputViewController
- 将自定义的 inputViewController 转化为 Keyboard Extension