跳到主要内容

构建 <InputAccessoryView> For React Native

·6 分钟阅读
Peter Argany
Facebook 软件工程师

动机

三年前,GitHub 上提出了一个 issue,要求支持 React Native 的输入附件视图。

在随后的几年里,出现了无数的“+1”回复、各种变通方法,以及关于此 issue 的 RN 零实际更改 - 直到今天。从 iOS 开始,我们正在公开一个 API 以访问原生输入附件视图,我们很高兴分享我们是如何构建它的。

背景

输入附件视图到底是什么?阅读 Apple 的开发者文档,我们了解到它是一个自定义视图,当接收器成为第一响应者时,可以锚定到系统键盘的顶部。任何从 UIResponder 继承的对象都可以将 .inputAccessoryView 属性重新声明为读写,并在此处管理自定义视图。响应者基础架构会挂载该视图,并使其与系统键盘保持同步。像拖动或点击这样的关闭键盘的手势在框架级别应用于输入附件视图。这使我们能够构建具有交互式键盘关闭功能的内容,这是 iMessage 和 WhatsApp 等顶级消息应用中不可或缺的功能。

将视图锚定到键盘顶部有两种常见的用例。第一种是创建键盘工具栏,例如 Facebook 编辑器的背景选择器。

在这种情况下,键盘焦点集中在文本输入字段上,输入附件视图用于提供额外的键盘功能。此功能与输入字段的类型相关。在地图应用中,它可以是地址建议,或者在文本编辑器中,它可以是富文本格式工具。


在这种情况下,拥有 <InputAccessoryView> 的 Objective-C UIResponder 应该很清楚。<TextInput> 已成为第一响应者,在底层,这变成了一个 UITextViewUITextField 的实例。

第二种常见的场景是粘性文本输入

在这里,文本输入实际上是输入附件视图本身的一部分。这通常用于消息应用,在这些应用中,可以在滚动浏览之前的消息线程时撰写消息。


在这个例子中,谁拥有 <InputAccessoryView>?它可以再次是 UITextViewUITextField 吗?文本输入在输入附件视图内部,这听起来像是一个循环依赖。仅解决这个问题本身就是 另一篇博文 的内容。剧透:所有者是一个通用的 UIView 子类,我们手动告诉它 becomeFirstResponder

API 设计

我们现在知道 <InputAccessoryView> 是什么,以及我们想要如何使用它。下一步是设计一个对这两种用例都有意义的 API,并且可以与现有的 React Native 组件(如 <TextInput>)良好地配合使用。

对于键盘工具栏,我们需要考虑以下几点

  1. 我们希望能够将任何通用的 React Native 视图层次结构提升到 <InputAccessoryView> 中。
  2. 我们希望这个通用的、分离的视图层次结构能够接受触摸并能够操作应用状态。
  3. 我们希望将 <InputAccessoryView> 链接到特定的 <TextInput>
  4. 我们希望能够在多个文本输入之间共享 <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 上的 Home 指示条。您可能会想,“Apple 开发了 safeAreaLayoutGuide 正是为了这个原因,这很简单!”。我们和您一样天真。第一个问题是,原生 <InputAccessoryView> 实现没有窗口可以锚定,直到它即将出现的那一刻。没关系,我们可以覆盖 -(BOOL)becomeFirstResponder 并在那里强制执行布局约束。遵守这些约束会将附件视图向上移动,但另一个错误出现了:

输入附件视图成功地避开了 Home 指示条,但现在不安全区域后面的内容是可见的。解决方案在于这个 雷达。我将原生 <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 版本中提供。

键盘操作愉快 :)