跳过至主要内容

为 React Native 构建 <InputAccessoryView>

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

动机

三年前,GitHub 上有人提出了一个问题,希望能从 React Native 中支持输入辅助视图 (input accessory view)。

在接下来的几年里,关于这个问题,出现了无数的“+1”评论、各种权宜之计,但 React Native 却没有任何具体改动——直到今天。从 iOS 开始,我们正在公开一个 API,用于访问原生的输入辅助视图,我们很高兴能分享我们是如何构建它的。

背景

究竟什么是输入辅助视图 (input accessory view)?阅读 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 的概念实现第一点。在这个设计中,我们将 React Native 视图“传送”到由响应者基础设施管理的 UIView 层级。由于 React Native 视图渲染为 UIViews,这实际上非常直接——我们只需覆盖

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

并将所有子视图传递到一个新的 UIView 层级。对于第二点,我们为 <InputAccessoryView> 设置了一个新的 RCTTouchHandler。状态更新通过使用常规事件回调来实现。对于第三点和第四点,我们在创建 <TextInput> 组件时,使用 nativeID 字段在原生代码中定位辅助视图的 UIView 层级。此函数使用底层原生文本输入框的 .inputAccessoryView 属性。这样做有效地将 <InputAccessoryView><TextInput> 在其 Objective-C 实现中链接起来。

支持粘性文本输入框(场景 2)增加了一些额外的约束。对于此设计,输入辅助视图包含一个文本输入框作为子视图,因此通过 nativeID 进行链接不是一个选择。相反,我们将一个通用的离屏 UIView.inputAccessoryView 设置为我们的原生 <InputAccessoryView> 层级。通过手动告诉这个通用 UIView 成为第一响应者,该层级由响应者基础设施挂载。这个概念在前面提到的博客文章中进行了详细解释。

陷阱

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

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

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


我们遇到的另一个棘手的 bug 是在 iPhone X 上避开主页指示条。您可能会想,“苹果正是为此目的开发了 safeAreaLayoutGuide,这很简单!”。我们当时也同样天真。第一个问题是,原生 <InputAccessoryView> 实现直到即将出现的那一刻才会有可锚定的窗口。没关系,我们可以重写 -(BOOL)becomeFirstResponder 并在那里强制执行布局约束。遵循这些约束会将辅助视图上移,但随之又出现了一个 bug:

输入辅助视图成功避开了主页指示条,但现在不安全区域后面的内容变得可见。解决方案存在于这个 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 版本中提供。

祝您键盘愉快 :)