跳至主内容

为 React Native 构建 <InputAccessoryView>

· 1 分钟阅读
Peter Argany
Facebook 软件工程师
非官方测试版翻译

本页面由 PageTurner AI 翻译(测试版)。未经项目官方认可。 发现错误? 报告问题 →

背景动机

三年前,有人提交了一个 GitHub issue,要求 React Native 支持 input accessory view。

在随后的几年里,这个问题收到了无数的 "+1" 和各种变通方案,但 RN 本身却没有任何实质改变——直到今天。我们从 iOS 平台开始,公开了访问原生 input accessory view 的接口,并很高兴分享我们的实现方案。

背景知识

究竟什么是 input accessory view?阅读 Apple 开发者文档可知,它是一个自定义视图,当接收者成为第一响应者时,可以固定在系统键盘顶部。任何继承自 UIResponder 的对象都可重新声明 .inputAccessoryView 属性,并在此管理自定义视图。响应者基础架构会挂载该视图,并使其与系统键盘保持同步。用于关闭键盘的手势(如拖动或点击)会在框架层应用于 input accessory view。这让我们能实现交互式键盘收起功能——这是 iMessage 和 WhatsApp 等顶级通讯应用的核心特性。

将视图固定在键盘顶部有两种常见场景:第一种是创建键盘工具栏,例如 Facebook 发帖编辑器的背景选择器。

在此场景中,键盘聚焦于文本输入框,input accessory view 用于提供额外的键盘功能。这些功能与输入框类型相关:地图应用中可能是地址建议,文本编辑器中则可能是富文本格式工具。


在此场景中,拥有 <InputAccessoryView> 的 Objective-C UIResponder 很明确:当 <TextInput> 成为第一响应者时,底层会变成 UITextViewUITextField 实例。

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

此时文本输入框本身是 input accessory view 的组成部分。这常见于消息应用,用户可在滚动查看历史消息的同时编写新消息。


此例中谁拥有 <InputAccessoryView>?还能是 UITextViewUITextField 吗?文本输入框竟_位于_ input accessory view 内部,这形成了循环依赖。单独解决这个问题就值得另写一篇博客。剧透:拥有者是一个通用的 UIView 子类,我们会手动触发它的 becomeFirstResponder 方法。

接口设计

现在我们知道 <InputAccessoryView> 是什么以及如何使用它。下一步是设计一个同时满足两种场景的接口,并与现有 React Native 组件(如 <TextInput>)良好协作。

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

  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 字段在原生代码中定位辅助视图的 UIView 层次结构。该函数利用底层原生文本输入的 .inputAccessoryView 属性,从而在 ObjC 实现中有效地将 <InputAccessoryView><TextInput> 关联起来。

支持粘性文本输入(场景二)带来额外约束。这种设计中输入辅助视图本身包含文本输入子元素,因此无法通过 nativeID 关联。我们改为将通用离屏 UIView.inputAccessoryView 属性设置为我们原生的 <InputAccessoryView> 层次结构。通过手动使该通用 UIView 成为第一响应者,响应者基础设施便会挂载该层次结构。此概念在前述博客文章中有详细阐述。

陷阱与解决方案

当然,在构建此 API 的过程中并非一帆风顺。以下是我们遇到的一些陷阱及其解决方案。

最初的构建方案涉及监听 NSNotificationCenter 的 UIKeyboardWill(Show/Hide/ChangeFrame) 事件。该模式在某些开源库和 Facebook 应用内部模块中使用。遗憾的是,在滑动操作时,UIKeyboardDidChangeFrame 事件未能及时触发以更新 <InputAccessoryView> 的帧位置。此外,键盘高度变化也不会被这些事件捕获,导致如下缺陷:

在 iPhone X 上,文本键盘与表情键盘高度不同。依赖键盘事件操控文本输入框的应用都需修复此缺陷。我们的解决方案是坚持使用 .inputAccessoryView 属性,这意味着响应者基础设施会处理此类帧更新。


另一个棘手问题是避免与 iPhone X 的 Home 指示条重叠。您可能认为:"苹果为此专门开发了 safeAreaLayoutGuide,这很简单!" 我们最初也如此天真。首要问题是原生 <InputAccessoryView> 实现在即将显示前没有可锚定的窗口。虽然可通过重写 -(BOOL)becomeFirstResponder 强制执行布局约束,但遵循这些约束会将辅助视图上移后,新问题又会出现:

输入辅助视图成功避开 Home 指示条后,不安全区域后的内容却变得可见。解决方案来自这个 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 版本中提供。

祝键盘操作愉快 :)