React Native 自定义软键盘

1,463 阅读7分钟

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
}

具体的组件设计思路是用 cloneElementchildren 重定义一次,拿到 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 进行自定义键盘,如果对你有帮助,点赞,收藏起来!