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 修改的通知会在键盘位置移动后继续发送。
  • 一般情况下,只要追踪最近一次发出的 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 
}}

不可滚动的布局

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

Screen Shot 2017-08-06 at 14.29.26

在这里我们有五个 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

Screen Shot 2017-07-14 at 2.08.54 P

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

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

让 Accessory View 拥有动态高度

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

Screen Shot 2017-07-29 at 8.14.45 P

如果用的是 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 很早就做到了这一点,这是怎么做到的呢?

Screen Shot 2017-07-29 at 10.20.32 P

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

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

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

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

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

Screen Shot 2017-07-30 at 6.40.23 P

每次键盘升起之前,我们自定义的 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 让开发者可以提供上下文给系统,让系统可以更好地完成输入预测自动纠正等等功能。

Screen Shot 2017-07-30 at 8.15.23 P

用户通过这些优化,甚至不需要打字,只需要点击 QuickType Bar 上系统提供的预测结果,就可以完成输入操作。

Screen Shot 2017-07-30 at 9.52.44 P

输入地址之前,如果我们打开原生的地图应用,搜索过某个地址的话,还可以在 QuickType Bar 看到这个地址,这是通过系统的 NSUserActivity 实现的,更多内容可以查看 WWWDC 2016 Session 240 Increasing Usage of Your App With Proactive Suggestion

Screen Shot 2017-07-30 at 10.03.00 P

今年 iOS 11 还给 UITextContentType 新增了两个预设值,让用户可以在 App 里直接使用 Safari 里记录的登录信息,自动填充用户名和密码,更多内容可以查看本书另一篇文章《iOS 11 里 App 终于可以密码自动填充了》。

更智能的输入

更智能的引号和破折号

Screen Shot 2017-07-30 at 10.08.19 P

今年 iOS 会自动将双引号转化为符合语境的双引号,连续的两个 - 会转化为短破折号,三个会转化为长破折号。

智能插入和删除

在手机上输入英文的时候,插入和删除永远都很烦人,除了增/删单词之后,你还总是需要重新移动光标去再处理单词前后的空格,今年 iOS 将会自动为用户处理前后空格的问题。

.default  
.yes  
.no

另外,以上提到的这些智能输入功能,默认都会开启(.default),但开发者也可以手动关闭一部分,例如输入快递单号的时候,就不需要自动纠正功能。我们只要通过 UITextInputTraits 去设置即可。

标记文本

Screen Shot 2017-07-30 at 11.33.14 P

提到文本输入,就不得不说标记文本,例如汉字,日文等等,总是需要先输入音符或者部首之类的信息,然后再从备选列表把要输入的字符挑选出来。

在 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之类的),触发的动作,快捷键的名称。

Screen Shot 2017-07-31 at 12.02.22 A

定义好之后,我们就可以在快捷键提示的界面看到我们定义的快捷键了。

自定义输入控件

Screen Shot 2017-07-31 at 1.04.31 A

Screen Shot 2017-07-31 at 1.54.24 P

Screen Shot 2017-07-31 at 1.54.22 P

前面我们提到了 UIKeyboardType,让开发者可以选择键盘的类型,但这些原生的键盘类型也许满足不了你的输入需求,例如计算器需要各种数学符号,Swift Playground 需要自动补齐,表格软件里需要公式等等,这个时候我们就需要自定义键盘了。

为我们的宠物自定义一个 Input View

这里我们以一个宠物专用的 Input View 为例,讲解一下如何自定义 Input View,首先我们的宠物需要使用特定的语言,并且能表达的词汇很有限。

Screen Shot 2017-07-31 at 2.07.05 P

我们可以看到自定义的 Input View 有三个字符可以输入,喂食,散步和暂停,像普通的键盘一样也是会出现在屏幕下方,一样有 Accessory View。
Screen Shot 2017-07-31 at 2.13.45 P

自定义 Input View 最简单的方式就是,继承 UIInputViewController 新建一个类,这样做有很多好处,例如跟系统键盘一样的灰色背景等等,更重要的是提供了一个 textDocumentProxy 去跟当前用户选择的 textField 进行交互,获得 textContentTypeinputTraits 之类的上下文信息,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 了。

Screen Shot 2017-08-01 at 9.18.54 A

首先我们需要在项目里新建一个 Target,选择 Custom Keyboard Extension,系统会自动为我们创建一个 KeyboardViewController,直接把 CustomInputView 的逻辑搬到这里就可以了。

Screen Shot 2017-08-02 at 9.45.59 A

或者是复用我们在项目里定义好的 AnimalInputViewController 类型,像上图一样打开 AnimalInputViewController 文件,然后在右边的 Target Membership 里把刚刚新增的 Target 打上勾,让 AnimalInputViewController 也加入 AnimalKeyboard 的编译(直接在 Build Phase 里加入也可以)。

Screen Shot 2017-08-01 at 10.36.47 P

然后打开 AnimalKeyboard 目录下的 info.plist,如图将 NSExtensionPrincipalClass 改为 ${PRODUCT_MODULE_NAME}.AnimalInputViewController(选择具体使用的 inputViewController 类型)。

Screen Shot 2017-08-02 at 9.31.57 A

最后,我们只要进入系统设置 -> PetChat(根据你的 App 名字不同而改变)**->** Keyboards -> AnimalKeyboard(Keyboard Extension 的 target 名称),将键盘开关打开,然后在别的 App 里也可以使用自定义的键盘了。

iOS 11 新增的 API

  • 选中的文字。现在的 inputViewController 可以通过 selectedText 获取到当前选中的文字。
  • documentIdentifierdocumentIdentifier 的作用是来区分开每一个 textField,例如当用户切换 textField 时你可以把输入预测给清空。
  • 请求获取最高访问的权限。现在可以跟用户发起请求,要求获取最高访问权限,以便创建更好的智能输入体验,后面会更详细地介绍最高访问权限的内容。

第三方键盘 API

  • 可以直接使用系统的输入法列表菜单。而不是像之前那样只能切换到下个输入法。

  • 个性化输入预测补齐。这里的个性化是指,你可以获取到用户的通讯录资料,在优化输入预测时非常有用。

  • 多语言支持。前面提过的 textInputContextIdentifier,如果你的输入法也支持多语言的话,就可以通过这个去优化多语言输入的体验。

    设计用户权限系统

  • 隐私。iOS 系统拥有一套隐私权限系统,让用户可以控制 App 可获取的隐私数据。* 使用用户信息。例如将用户信息发送到云端进行分析,一定要保证有一套用户协议机制,在用户统一的情况下才可以使用用户的使用信息。

  • 最高访问权限的请求。Keyboard Extension 可以通过向用户请求最高访问权限,来获取更多用户信息以优化输入体验。

最高访问权限

这件事情的价值不在于最高访问权限的获取,很多功能都是原生支持的,不一定需要这么高的权限,权限的获取只是对于现有功能的补充,而不是必要的。

其中一个需要获取访问权限的场景是,keyboard 需要跟主 App 交互,例如访问数据库里的热门词条。或者是网络的请求,当前的地点,通讯录等等,需要注意的是通讯录本来就可以获取,只是在获取一些额外信息的时候需要申请权限,例如联系人备注之类的。

总结

  • 如何让键盘更好地与 App 进行交互
    • 动态适应键盘高度,调整 App 布局
    • 使用 Accessory View 来对于键盘进行功能补充
  • 使用高级的智能输入功能,去优化用户体验
    • 通过 textInputContextIdentifier 优化多语言输入
    • 使用 textContentType 让系统提供登录信息自动补齐的功能
    • 使用 markedText 获取输入结果预测
    • 为实体键盘创建快捷键
  • 构建一个 Keyboard Extension 比你想象的更简单
    • 自定义 inputView/inputViewController
    • 将自定义的 inputViewController 转化为 Keyboard Extension