React Native 自定义软键盘
很多手机应用都有自定义键盘的需求,本文将介绍如何在 React Native 中,为 iOS 和 Android 两端的应用编写自定义键盘。
准备工作
基本的 React Native 起码要会吧?起项目直接用最新的版本,具体请看React Native 官网。 推荐把这个单独功能抽成 NPM 包。
npx create-react-native-library@latest react-native-custom-keyboard
# 运行后会有交互,前面的问答可以按需自行填写,这几个要仔细一下。
# What type of library do you want to develop?
# 选 Native View
# Which languages do you want to use?
# 选 Java & Objective-C
创建出来文件目录是这样的:
- android
- ios
- src
- package.json
- react-native-custom-keyboard.podspec
注意一点,如果在已有项目中运行上述命令,会直接在 package.json 对该项目进行软链接,由于 Metro 不支持软连接,请把相关引用改成硬连接。
// 改成相对路径,而不是软连接
{
...
"react-native-custom-keyboard": "./react-native-custom-keyboard"
...
}
思路介绍
为了保证UI一致性,整个 UI 应该在 JavaScript 端构建。由于 React Native 的 TextInput 并未提供简易的软键盘替换功能,所以,我需要进行原生开发。 我预想的 API 是设计一个 KeyboardProvider 包裹 TextInput,类似这样:
...
KeyboardProvider.register("CustomKeyboard", () => CustomKeyboard);
<KeyboardProvider name="CustomKeyboard">
<TextInput ref={inputRef} />
</KeyboardProvider>
当 TextInput 聚焦的时候,软键盘处会弹出 <CustomKeyboard /> 中的内容。
下面,开始思路介绍和代码展示。
JavaScript 代码
首先,先定义一下 JS 端跟原生的交互接口。我们需要有操作可以把 React 元素放入软键盘中;还需要在销毁TextInput销毁时,删除已注册元素的操作;最后,还有触发字符增减的操作。
CustomKeyboardModule
为了满足上述条件,CustomKeyboardModule 接口定义成这样。
// CustomKeyboardModule.ts
interface CustomKeyboardModule {
install: (tag: number, width: number, height: number) => void
uninstall: (tag: number) => void
append: (tag: number, text: string) => void
backspace: (tag: number) => void
}
export const customKeyboardModule: CustomKeyboardModule = NativeModules.CustomKeyboardModule
先解释一下其中的参数。
- tag: TextInput 在 React 中的 id,通过它可以在原生代码找到指定的 TextInput 的引用实例。
- name: 注册视图表中的视图唯一标识,就是通过 React Native 自带的通信机制,可以在原生端拿到用 React 写的自定义键盘组件。
- width 和 height: 软键盘的宽高。如果你没有特殊化需求,其实这个可以设个默认值就行。
KeyboardProvider 设计思路
KeyboardProvider 按照上述的第一部分 API 设计,应该设计成这样。
interface KeyboardProviderProps {
name: string
children: React.ReactElement
}
具体的组件设计思路是用 cloneElement 把 children 重定义一次,拿到 TextInput 在 React 中的实例,通过 findNodeHandle,找到这个 TextInput 的 tag,然后再调用 customKeyboardModule.install 绑定一下。
export function KeyboardProvider({
name,
children
}: MdbKeyboardProviderProps) {
const inputRef = React.useRef<TextInput>();
// composeRef 的主要作用是为了让 TextInput 原有的ref能够在不破坏声明的前提下设计的。
const composeRef = React.useCallback((inputRef) => {
return (ref: any) => {
inputRef.current = ref;
if (typeof inputRef === "function") {
inputRef(ref);
} else if (inputRef) {
inputRef.current = ref;
}
}
}, []);
React.useEffect(() => {
const node = inputRef.current;
if (!node) {
return;
}
const tag = findNodeHandle(node);
if (!tag) {
return;
}
node.measure(() => {
const size = Dimensions.get("screen");
// 宽使用的是屏幕宽度,高默认给的是 300px
// 觉得不妥可以自行修改
mdbKeyboardModule.install(tag, name, size.width, 300);
});
return () => {
mdbKeyboardModule.uninstall(tag);
};
}, [context]);
return React.cloneElement(children, {
...children.props,
ref: composeRef(children.ref)
});
};
到这里,还需要再给 KeyboardProvider 添加注册函数。
MdbKeyboardProvider.register = (name: string, Keyboard: React.ComponentType<KeyboardComponentProps>) => {
interface WrappedKeyboardProps {
tag: number
}
function WrappedKeyboard({ tag }: WrappedKeyboardProps) {
const handleAppend = (value: string) => {
customKeyboardModule.append(tag, value);
}
const handleBackspace = () => {
customKeyboardModule.backspace(tag);
}
return <Keyboard onAppend={handleAppend} onBackspace={handleBackspace} />
}
AppRegistry.registerComponent(name, () => WrappedKeyboard);
}
iOS 接口
打开 ios 目录会发现命令帮你创建了一组类文件 CustomKeyboard.h 和 CustomKeyboard.mm ,后续的代码只会在 CustomKeyboard.mm 文件中修改。现在我需要在原生端创建接口以便 JS 端可以调用。
// 对应 NativeModules.customKeyboardModule
RCT_EXPORT_MODULE(CustomKeyboardModule)
// 对应 CustomKeyboardModule.install
RCT_EXPORT_METHOD(install:(nonnull NSNumber *)tag name:(nonnull NSString *)name width:(nonnull NSNumber *)width height:(nonnull NSNumber *)height)
{
// 添加相关函数
}
// 对应 CustomKeyboardModule.uninstall
RCT_EXPORT_METHOD(uninstall:(nonnull NSNumber *)tag)
{
// 添加相关函数
}
// 对应 CustomKeyboardModule.append
RCT_EXPORT_METHOD(append:(nonnull NSNumber *)tag text:(NSString *)text)
{
// 添加相关函数
}
// 对应 CustomKeyboardModule.backspace
RCT_EXPORT_METHOD(backspace:(nonnull NSNumber *)tag)
{
// 添加相关函数
}
install
在 iOS 中,TextField 和 TextView 提供了 inputView 属性,它可以帮助你快速自定义软键盘。
在 React Native 中,TextInput 组件基于 RCTBaseTextInputView ,派生出了两个类 RCTSinglelineTextInputView 和 RCTMultilineTextInputView ,分别封装了 TextField 和 TextView。我可以通过 RCTBaseTextInputView.backedTextInputView.inputView 可以直接拿到软键盘属性,而不用区分他们是什么类型。之后,将软键盘组件实例化并设置给该属性。
RCT_EXPORT_METHOD(install:(nonnull NSNumber *)tag name:(nonnull NSString *)name width:(nonnull NSNumber *)width height:(nonnull NSNumber *)height)
{
// self.bridge.uiManager 通过 tag 可以找到 textInput 组件在 iOS 中具体的实例
RCTBaseTextInputView *textInput = (RCTBaseTextInputView *)[self.bridge.uiManager viewForReactTag:tag];
if (![textInput isKindOfClass:[RCTBaseTextInputView class]]) {
return;
}
// 软键盘组件实例化,此处的 initialProperties 会传给 JS 但是由于不能直接传事件,只能通过 tag 找到当前的 TextInput
UIView* inputView = [[RCTRootView alloc] initWithFrame:CGRectMake(0, 0, [width intValue], [height intValue])
bridge:self.bridge
moduleName:name
initialProperties:@{ @"tag": tag }];
// 设置软键盘
textInput.backedTextInputView.inputView = inputView;
[textInput.backedTextInputView reloadInputViews];
}
uninstall
卸载操作需要把 RCTBaseTextInputView.backedTextInputView.inputView 设为 nil
// 对应 CustomKeyboardModule.uninstall
RCT_EXPORT_METHOD(uninstall:(nonnull NSNumber *)tag)
{
RCTBaseTextInputView *textInput = (RCTBaseTextInputView *)[self.bridge.uiManager viewForReactTag:tag];
if (![textInput isKindOfClass:[RCTBaseTextInputView class]]) {
return;
}
textInput.backedTextInputView.inputView = nil;
[textInput.backedTextInputView reloadInputViews];
}
append
RCTBaseTextInputView.backedTextInputView 把 singleline 和 mutiline 的一些共有操作抽象了出来,为了可以让新增字符在光标后面增加,需要通过 selectedTextRange 定位光标位置,并把按键对应的字符填入。
RCT_EXPORT_METHOD(append:(nonnull NSNumber *)tag text:(NSString *)text) {
RCTBaseTextInputView *textInput = [self textViewforReactTag:tag];
if (textInput == nil) {
return;
}
[textInput.backedTextInputView replaceRange:textInput.backedTextInputView.selectedTextRange withText:text];
}
backspace
回退操作也类似,光标如果是没有选择的,那么把起点往左移一格,然后移除。
RCT_EXPORT_METHOD(backspace:(nonnull NSNumber *)tag) {
RCTBaseTextInputView *textInput = [self textViewforReactTag:tag];
if (textInput == nil) {
return;
}
UITextRange* range = textInput.backedTextInputView.selectedTextRange;
if ([textInput.backedTextInputView comparePosition:range.start toPosition:range.end] == 0) {
UITextPosition * fromPosition = [textInput.backedTextInputView positionFromPosition:range.start offset:-1];
range = [textInput.backedTextInputView textRangeFromPosition:fromPosition toPosition:range.start];
}
[textInput.backedTextInputView replaceRange:range withText:@""];
}
以上,就完成了 iOS 端原生模块的定义。
Android 接口
打开 android 目录会发现命令帮你创建了一个 Android Project,模块的代码在 src/main/java/com/customkeyboard/CustomKeyboardModule.java 。只用改这部分的代码就可以了。
public class CustomKeyboardModule extends ReactContextBaseJavaModule {
// 对应 install
@ReactMethod
public void install(final int tag, final String name, final int width, final int height) {
...
}
@ReactMethod
public void uninstall(final int tag) {
...
}
@ReactMethod
public void append(final int tag, final String text) {
...
}
@ReactMethod
public void backspace(final int tag) {
...
}
}
install
在 Android 中,TextInput 对应的实体类是 ReactEditText ,他提供 InputConnection 进行输入监控。如果要自定义键盘,可以调用 ReactEditText.setShowSoftInputOnFocus(false) ,让系统键盘不显示,并显示当前的键盘。
这里,先给出一些辅助方法。
// 定义一个 inputConnection 的 KV 对象
private HashMap<Integer, InputConnection> icMap;
// 通过 tag 获取具体的 TextEdit 实例
private ReactEditText getEditByTag(final int tag) {
UIManagerModule module = this.getReactApplicationContext().getNativeModule(UIManagerModule.class);
return (ReactEditText)module.resolveView(tag);
}
// 把软键盘组件放入 RelativeLayout 中
private View createCustomKeyboardLayout(final Activity activity, final String name, final String tag, final int width, final int height) {
Bundle initProps = new Bundle();
initProps.putInt("tag", tag);
ReactRootView keyboardView = new ReactRootView(this.getReactApplicationContext());
keyboardView.startReactApplication(
((ReactApplication) activity.getApplication()).getReactNativeHost().getReactInstanceManager(),
name,
initProps);
// 对于 Android 来说,有逻辑像素和实际像素有区别,需要手动做映射。
final DisplayMetrics metrics = activity.getResources().getDisplayMetrics();
final float scale = metrics.density;
RelativeLayout.LayoutParams lParams = new RelativeLayout.LayoutParams(Math.round(width * scale), Math.round(height * scale));
// 让软键盘从底部初始化
lParams.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM, RelativeLayout.TRUE);
RelativeLayout layout = new RelativeLayout(activity);
layout.addView(keyboardView, lParams);
return layout;
}
最后, install 的代码是这样的。
@ReactMethod
public void install(final int tag, final String name, final int width, final int height) {
UiThreadUtil.runOnUiThread(() -> {
final Activity activity = getCurrentActivity();
final ReactEditText edit = getEditByTag(tag);
if (activity == null || edit == null) {
return;
}
// 创建 RelativeLayout Keyboard 实例
View keyboardLayout = createCustomKeyboardLayout(activity, name, width, height);
keyboardLayout.setVisibility(View.INVISIBLE);
// 把 layout 加入到 activity 里面
activity.addContentView(layout, new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
edit.setTag(TAG_ID, keyboardLayout);
// 防止系统键盘出现
edit.setShowSoftInputOnFocus(false);
// 缓存一组 inputConnection
icMap.put(tag, edit.onCreateInputConnection(new EditorInfo()));
edit.setOnFocusChangeListener((view, hasFocus) -> {
final int keyboardHeight = keyboardLayout.getHeight();
if (hasFocus) {
// 上滑动画
keyboardLayout.setVisibility(View.VISIBLE);
TranslateAnimation slide = new TranslateAnimation(0, 0, keyboardHeight, 0);
slide.setDuration(300);
slide.setFillAfter(true);
keyboardLayout.startAnimation(slide);
} else {
// 下滑动画
keyboardLayout.setVisibility(View.INVISIBLE);
TranslateAnimation slide = new TranslateAnimation(0, 0, 0, keyboardHeight);
slide.setDuration(300);
slide.setFillAfter(true);
keyboardLayout.startAnimation(slide);
}
});
});
}
uninstall
uninstall 的代码是这样的。
@ReactMethod
public void uninstall(final int tag) {
UiThreadUtil.runOnUiThread(() -> {
final ReactEditText edit = getEditByTag(tag);
if (edit == null) {
return;
}
View keyboard = (View)edit.getTag(TAG_ID);
// 移除视图实例
((ViewGroup) keyboard.getParent()).removeView(keyboard);
edit.setTag(TAG_ID, null);
// 移除缓存
icMap.remove(tag);
});
}
append
append 的代码是这样的,跟 iOS 差不多。
@ReactMethod
public void append(final int tag, final String text) {
UiThreadUtil.runOnUiThread(() -> {
final ReactEditText edit = getEditByTag(tag);
if (edit == null) {
return;
}
// 获取缓存的 inputConnection
InputConnection ic = icMap.get(tag);
//
ic.commitText(text, 1);
});
}
backspace
backspace 的接口是这样的,跟 iOS 差不多。
@ReactMethod
public void backspace(final int tag) {
UiThreadUtil.runOnUiThread(() -> {
final ReactEditText edit = getEditByTag(tag);
if (edit == null) {
return;
}
InputConnection ic = icMap.get(tag);
CharSequence selectedText = ic.getSelectedText(0);
if (TextUtils.isEmpty(selectedText)) {
// 没有选择,直接删除前一个字符
ic.deleteSurroundingText(1, 0);
} else {
// 删除已选内容
ic.commitText("", 1);
}
});
}
总结
给大家介绍了如何使用 React Native 进行自定义键盘,如果对你有帮助,点赞,收藏起来!