构建 <InputAccessoryView> 用于 React Native
动机
三年前,在 GitHub 上提出了一个支持从 React Native 访问输入附件视图的问题。
在随后的几年里,出现了无数的“+1”、各种解决方法,以及针对此问题的零具体更改 - 直到今天。从 iOS 开始,我们公开了访问原生输入附件视图的 API,我们很高兴与大家分享我们是如何构建它的。
背景
输入附件视图究竟是什么?阅读Apple 的开发者文档,我们了解到它是一个自定义视图,每当接收器成为第一响应者时,它都可以锚定到系统键盘的顶部。任何继承自UIResponder
的对象都可以将.inputAccessoryView
属性重新声明为读写,并在此处管理自定义视图。响应者基础结构挂载视图,并使其与系统键盘保持同步。在框架级别将诸如拖动或点击之类的关闭键盘的手势应用于输入附件视图。这使我们能够构建具有交互式键盘关闭功能的内容,这是 iMessage 和 WhatsApp 等顶级消息应用中的一个重要功能。
将视图锚定到键盘顶部的两种常见用例。第一个是创建键盘工具栏,例如 Facebook 撰写器背景选择器。
在这种情况下,键盘聚焦于文本输入字段,并且输入附件视图用于提供其他键盘功能。此功能与输入字段的类型相关。在地图应用程序中,它可能是地址建议,或者在文本编辑器中,它可能是富文本格式化工具。
在这种情况下,拥有<InputAccessoryView>
的 Objective-C UIResponder 应该很清楚。<TextInput>
已成为第一响应者,在底层它变成了UITextView
或UITextField
的实例。
第二种常见情况是粘性文本输入
在这里,文本输入实际上是输入附件视图本身的一部分。这通常用于消息应用程序中,在其中可以在滚动浏览先前消息线程时撰写消息。
在此示例中,谁拥有<InputAccessoryView>
?它可以再次是UITextView
或UITextField
吗?文本输入位于输入附件视图内部,这听起来像是一个循环依赖关系。仅解决此问题本身就是另一篇博文。剧透:所有者是通用的UIView
子类,我们手动告诉它成为第一响应者。
API 设计
现在我们知道了<InputAccessoryView>
是什么,以及我们如何使用它。下一步是设计一个对这两种用例都有意义的 API,并且可以与<TextInput>
等现有的 React Native 组件配合使用。
对于键盘工具栏,我们需要考虑一些事项
- 我们希望能够将任何通用的 React Native 视图层次结构提升到
<InputAccessoryView>
中。 - 我们希望这个通用且分离的视图层次结构能够接受触摸并能够操作应用程序状态。
- 我们希望将
<InputAccessoryView>
链接到特定的<TextInput>
。 - 我们希望能够在多个文本输入之间共享
<InputAccessoryView>
,而无需复制任何代码。
我们可以使用类似于React 门户的概念来实现 #1。在此设计中,我们将 React Native 视图门户到由响应者基础结构管理的UIView
层次结构中。由于 React Native 视图呈现为 UIViews,因此这实际上非常简单 - 我们只需覆盖
- (void)insertReactSubview:(UIView *)subview atIndex:(NSInteger)atIndex
并将所有子视图传递到一个新的 UIView 层次结构。对于 #2,我们为<InputAccessoryView>
设置了一个新的RCTTouchHandler。状态更新是通过使用常规事件回调来实现的。对于 #3 和 #4,我们使用nativeID字段在创建<TextInput>
组件期间在原生代码中找到附件视图 UIView 层次结构。此函数使用底层原生文本输入的.inputAccessoryView
属性。这样做有效地将<InputAccessoryView>
链接到其 ObjC 实现中的<TextInput>
。
支持粘性文本输入(场景 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 版本中可用。
祝您打字愉快 :)