构建 <InputAccessoryView> For React Native
动机
三年前,在 GitHub 上提出了一个 issue,要求支持来自 React Native 的输入配件视图。

在随后的几年中,出现了无数的“+1s”、各种变通方法,以及关于此 issue 的 RN 零实际更改 - 直到今天。从 iOS 开始,我们正在公开一个 API 以访问原生输入配件视图,我们很高兴分享我们是如何构建它的。
背景
输入配件视图到底是什么?阅读 Apple 的开发者文档,我们了解到它是一个自定义视图,当接收者成为第一响应者时,可以锚定到系统键盘的顶部。任何继承自 UIResponder
的东西都可以将 .inputAccessoryView
属性重新声明为读写,并在此处管理自定义视图。响应者基础设施会挂载该视图,并使其与系统键盘保持同步。框架级别将手势(如拖动或点击)应用于输入配件视图,从而关闭键盘。这使我们能够构建具有交互式键盘关闭功能的内容,这是 iMessage 和 WhatsApp 等顶级消息应用程序的组成部分。
将视图锚定到键盘顶部有两个常见的用例。第一个是创建键盘工具栏,例如 Facebook 编辑器背景选择器。

在这种情况下,键盘焦点位于文本输入字段上,输入配件视图用于提供额外的键盘功能。此功能与输入字段的类型相关。在地图应用程序中,它可以是地址建议,或者在文本编辑器中,它可以是富文本格式工具。
在这种情况下,拥有 <InputAccessoryView>
的 Objective-C UIResponder 应该很明确。<TextInput>
已成为第一响应者,在底层,这变成 UITextView
或 UITextField
的实例。
第二个常见的场景是粘性文本输入

在这里,文本输入实际上是输入配件视图本身的一部分。这在消息应用程序中很常见,在消息应用程序中,可以在滚动浏览之前的消息线程时撰写消息。
在这个例子中,谁拥有 <InputAccessoryView>
?它可以再次是 UITextView
或 UITextField
吗?文本输入在输入配件视图内部,这听起来像是一个循环依赖。仅解决这个问题本身就是 另一篇博客文章。剧透:所有者是一个通用的 UIView
子类,我们手动告诉它 becomeFirstResponder。
API 设计
我们现在知道 <InputAccessoryView>
是什么,以及我们想要如何使用它。下一步是设计一个对这两种用例都有意义的 API,并且可以很好地与现有的 React Native 组件(如 <TextInput>
)配合使用。
对于键盘工具栏,我们需要考虑以下几点
- 我们希望能够将任何通用的 React Native 视图层次结构提升到
<InputAccessoryView>
中。 - 我们希望这个通用的、分离的视图层次结构能够接受触摸并能够操作应用程序状态。
- 我们希望将
<InputAccessoryView>
链接到特定的<TextInput>
。 - 我们希望能够在多个文本输入之间共享
<InputAccessoryView>
,而无需复制任何代码。
我们可以使用类似于 React portals 的概念来实现 #1。在这个设计中,我们将 React Native 视图传送到由响应者基础设施管理的 UIView
层次结构。由于 React Native 视图渲染为 UIView,所以这实际上非常简单 - 我们只需覆盖
- (void)insertReactSubview:(UIView *)subview atIndex:(NSInteger)atIndex
并将所有子视图管道传输到新的 UIView 层次结构。对于 #2,我们为 <InputAccessoryView>
设置一个新的 RCTTouchHandler。状态更新是通过使用常规事件回调来实现的。对于 #3 和 #4,我们在创建 <TextInput>
组件期间,使用 nativeID 字段在原生代码中定位配件视图 UIView 层次结构。此函数使用底层原生文本输入的 .inputAccessoryView
属性。这样做有效地将 <InputAccessoryView>
链接到 <TextInput>
在它们的 ObjC 实现中。
支持粘性文本输入(场景 2)增加了一些限制。对于此设计,输入配件视图具有作为子项的文本输入,因此通过 nativeID 链接不是一种选择。相反,我们将通用屏幕外 UIView
的 .inputAccessoryView
设置为我们的原生 <InputAccessoryView>
层次结构。通过手动告诉这个通用的 UIView
成为第一响应者,层次结构由响应者基础设施挂载。这个概念在前面提到的博客文章中得到了充分的解释。
陷阱
当然,在构建此 API 时并非一切都一帆风顺。以下是我们遇到的一些陷阱,以及我们如何修复它们。
构建此 API 的最初想法之一是监听 NSNotificationCenter
的 UIKeyboardWill(Show/Hide/ChangeFrame) 事件。这种模式在一些开源库中使用,并在 Facebook 应用程序的某些部分内部使用。不幸的是,UIKeyboardDidChangeFrame
事件没有及时调用以更新滑动时的 <InputAccessoryView>
框架。此外,键盘高度的变化不会被这些事件捕获。这会产生一类像这样的错误

在 iPhone X 上,文本和表情符号键盘的高度不同。大多数使用键盘事件来操作文本输入框架的应用程序都必须修复上述错误。我们的解决方案是致力于使用 .inputAccessoryView
属性,这意味着响应者基础设施处理像这样的框架更新。
我们遇到的另一个棘手错误是避免 iPhone X 上的主屏幕指示条。您可能会想,“Apple 开发了 safeAreaLayoutGuide 正是为此目的,这很简单!”。我们和您一样天真。第一个问题是,原生 <InputAccessoryView>
实现没有窗口可以锚定,直到它即将出现的那一刻。没关系,我们可以覆盖 -(BOOL)becomeFirstResponder
并在那里强制执行布局约束。遵守这些约束会将配件视图向上移动,但另一个错误出现了:
输入配件视图成功地避开了主屏幕指示条,但现在不安全区域后面的内容是可见的。解决方案在于这个 radar。我将原生 <InputAccessoryView>
层次结构包装在一个不符合 safeAreaLayoutGuide
约束的容器中。原生容器覆盖了不安全区域的内容,而 <InputAccessoryView>
则保持在安全区域边界内。
使用示例
这是一个示例,它构建了一个键盘工具栏按钮来重置 <TextInput>
状态。
class TextInputAccessoryViewExample extends React.Component<
{},
*,
> {
constructor(props) {
super(props);
this.state = {text: 'Placeholder Text'};
}
render() {
const inputAccessoryViewID = 'inputAccessoryView1';
return (
<View>
<TextInput
style={styles.default}
inputAccessoryViewID={inputAccessoryViewID}
onChangeText={text => this.setState({text})}
value={this.state.text}
/>
<InputAccessoryView nativeID={inputAccessoryViewID}>
<View style={{backgroundColor: 'white'}}>
<Button
onPress={() =>
this.setState({text: 'Placeholder Text'})
}
title="Reset Text"
/>
</View>
</InputAccessoryView>
</View>
);
}
}
我什么时候可以使用它?
此功能实现的完整提交是 这里。<InputAccessoryView>
将在即将到来的 v0.55.0 版本中提供。
键盘操作愉快 :)