跳到主要内容

为 React Native 构建 <InputAccessoryView>

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

动机

三年前,GitHub 上有一个 issue 被提出,要求 React Native 支持输入辅助视图。

接下来的几年里,关于这个问题,已经有无数的“+1”评论、各种变通方法,以及 RN 上零星的实质性更改——直到今天。从 iOS 开始,我们正在公开一个 API 来访问原生的输入附件视图,并且我们很高兴地分享我们是如何构建它的。

背景

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

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

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


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

第二种常见情况是固定文本输入

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


此示例中的 <InputAccessoryView> 由谁拥有?能是 UITextViewUITextField 吗?文本输入位于输入附件视图的内部,这听起来像是一个循环依赖。仅解决这个问题本身就是另一篇博文。剧透一下:所有者是一个通用的 UIView 子类,我们手动将其设置为 becomeFirstResponder

API 设计

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

对于键盘工具栏,有几件事需要考虑:

  1. 我们希望能够将任何通用 React Native 视图层次结构提升到 <InputAccessoryView> 中。
  2. 我们希望这个通用且分离的视图层次结构能够接受触摸并能够操纵应用程序状态。
  3. 我们希望将<InputAccessoryView>链接到特定的<TextInput>
  4. 我们希望能够在多个文本输入之间共享一个<InputAccessoryView>,而无需复制任何代码。

我们可以使用类似于 React portals 的概念来实现 #1。在此设计中,我们将 React Native 视图“传送”到一个由响应者基础结构管理的 UIView 层级结构中。由于 React Native 视图渲染为 UIViews,这实际上非常简单——我们只需重写

- (void)insertReactSubview:(UIView *)subview atIndex:(NSInteger)atIndex

并将所有子视图管道化到一个新的 UIView 层级结构中。对于 #2,我们为 <InputAccessoryView> 设置了一个新的 RCTTouchHandler。状态更新是通过使用常规事件回调实现的。对于 #3 和 #4,我们在创建 <TextInput> 组件时,使用 nativeID 字段来定位原生代码中的 accessory view UIView 层级结构。此函数使用底层原生文本输入的 .inputAccessoryView 属性。这样做有效地在它们的 ObjC 实现中将 <InputAccessoryView><TextInput> 链接起来。

支持粘性文本输入(场景2)增加了一些额外的限制。对于这种设计,输入附件视图有一个文本输入作为子视图,因此通过 nativeID 链接不是一个选项。相反,我们将通用屏幕外 UIView.inputAccessoryView 设置为我们的原生 <InputAccessoryView> 层次结构。通过手动告知这个通用 UIView 成为第一响应者,层次结构由响应者基础设施挂载。这个概念在前面提到的博客文章中得到了详细解释。

陷阱

当然,在构建这个API时并非一帆风顺。以下是我们遇到的一些陷阱,以及我们如何修复它们。

构建此 API 的一个初步想法是监听 NSNotificationCenter 的 UIKeyboardWill(Show/Hide/ChangeFrame) 事件。这种模式在一些开源库中以及 Facebook 应用程序的某些部分内部使用。不幸的是,UIKeyboardDidChangeFrame 事件没有及时调用来更新滑动时的 <InputAccessoryView> 帧。此外,键盘高度的变化也未被这些事件捕获。这导致了一类如下所示的错误:

在 iPhone X 上,文本和表情符号键盘的高度不同。大多数使用键盘事件来操纵文本输入帧的应用程序都必须修复上述错误。我们的解决方案是致力于使用 .inputAccessoryView 属性,这意味着响应者基础设施会处理此类帧更新。


我们遇到的另一个棘手的 bug 是如何避免 iPhone X 上的 home pill(底部横条)。你可能会想,“Apple 开发 safeAreaLayoutGuide 就是为此目的,这很简单!”我们也曾同样天真。第一个问题是,原生 <InputAccessoryView> 实现直到即将出现的那一刻都没有窗口可以锚定。没关系,我们可以重写 -(BOOL)becomeFirstResponder 并在那里强制执行布局约束。遵守这些约束会将 accessory view 向上推,但会出现另一个 bug:

输入附件视图成功避开了 home pill,但现在不安全区域后面的内容变得可见了。解决方案在于这个 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>
);
}
}

在仓库中可以找到 Sticky Text Inputs 的另一个示例

我什么时候能使用它?

此功能实现的完整提交 在此<InputAccessoryView> 将在即将发布的 v0.55.0 版本中提供。

快乐打字 :)