RN中键盘处理和注意点

1,503 阅读7分钟

RN中处理键盘一般使用官方提供的组件KeyboardAvoidingView来避免内容被遮挡,而且使用也比较简单。但有时候它并不能按照我们预期的那样工作,比如:

video.gif

可以看到其并没有将聚焦的输入框给显示出来,还是遮挡了部分内容。这是为什么呢?

要了解其原因,我们需要简单了解一下其实现原理。

KeyboardAvoidingView 原理

通过查看KeyboardAvoidingView源码发现,其实现原理也比较简单:KeyboardAvoidingView会通过View包裹children组件,然后监听键盘事件,在键盘显示的时候获取键盘的高度和坐标,然后再根据behavior属性,设置ViewpaddingBottom或者height属性,达到让children组件显示在键盘之上的效果。

一、使用View包裹children组件

// height模式
  <View
    ref={this.viewRef}
    style={StyleSheet.compose(style, heightStyle)}
    onLayout={this._onLayout}
    {...props}>
    {children}
  </View>

// positon模式
  <View
    ref={this.viewRef}
    style={style}
    onLayout={this._onLayout}
    {...props}>
    <View
      style={StyleSheet.compose(contentContainerStyle, {
        bottom: bottomHeight,
      })}>
      {children}
    </View>
  </View>

// padding模式
  <View
    ref={this.viewRef}
    style={StyleSheet.compose(style, {paddingBottom: bottomHeight})}
    onLayout={this._onLayout}
    {...props}>
    {children}
  </View>

这里positon模式有点特殊,使用View又包裹了一层children,通过设置该Viewbottom的方式让children整体显示到键盘之上

而且这里会通过onLayout事件拿到childrenframe(大小和位置,在后续计算Viewbottom时会用到)

Note⚠️ : onLayout获取的y值是相对于父组件的,而不是屏幕的,在下节会提到这点

二、监听键盘事件

  componentDidMount(): void {
    if (Platform.OS === 'ios') {
      this._subscriptions = [
        Keyboard.addListener('keyboardWillChangeFrame', this._onKeyboardChange),
      ];
    } else {
      this._subscriptions = [
        Keyboard.addListener('keyboardDidHide', this._onKeyboardChange),
        Keyboard.addListener('keyboardDidShow', this._onKeyboardChange),
      ];
    }
  }

在组件挂载之后就会监听键盘事件,在监听事件里会计算 Viewbottom 值:

 _onKeyboardChange = (event: ?KeyboardEvent) => {
    this._keyboardEvent = event;
    // $FlowFixMe[unused-promise]
    this._updateBottomIfNecessary();
  };
  
  // 根据键盘调整 View
  _updateBottomIfNecessary = async () => {
    if (this._keyboardEvent == null) {
      this._setBottom(0);
      return;
    }

    const {duration, easing, endCoordinates} = this._keyboardEvent;
    // 计算需要偏移的距离
    const height = await this._relativeKeyboardHeight(endCoordinates);

    if (this._bottom === height) {
      return;
    }
    // 更新View的bottom 让View不与键盘重叠
    this._setBottom(height);

    const enabled = this.props.enabled ?? true;
    if (enabled && duration && easing) {
    // 配置View弹出的动画
      LayoutAnimation.configureNext({
        // We have to pass the duration equal to minimal accepted duration defined here: RCTLayoutAnimation.m
        duration: duration > 10 ? duration : 10,
        update: {
          duration: duration > 10 ? duration : 10,
          type: LayoutAnimation.Types[easing] || 'keyboard',
        },
      });
    }
  };

键盘事件的参数KeyboardEvent里包含:

  1. duration: 动画时长,单位毫秒
  2. easing: 键盘动画效果
  3. endCoordinates: 键盘弹出后的坐标信息
    • screenX: 键盘的X坐标
    • screenY: 键盘Y坐标(相对于屏幕的)
    • width: 键盘宽度
    • height: 键盘高度
  4. startCoordinates: 键盘弹出前的坐标信息(仅iOS)

下面是计算View需要偏移的大小

 async _relativeKeyboardHeight(
    keyboardFrame: KeyboardMetrics,
  ): Promise<number> {
    const frame = this._frame;
    if (!frame || !keyboardFrame) {
      return 0;
    }
    
    ... other code

    const keyboardY =
      keyboardFrame.screenY - (this.props.keyboardVerticalOffset ?? 0);

    if (this.props.behavior === 'height') {
      return Math.max(
        this.state.bottom + frame.y + frame.height - keyboardY,
        0,
      );
    }
    // 计算View需要偏移的大小
    return Math.max(frame.y + frame.height - keyboardY, 0);
  }

大致步骤就是:在onLayout回调里获取Viewframe, 在键盘弹起之后,获取键盘的Y坐标,通过View的最大Y坐标(frame.y + frame.height) 与键盘的Y坐标进行相减,得到View应该设置的bottom值。 这样如果键盘遮挡了ViewView会根据behavior类型设置响应的偏移量(padding/height/position),让View能够显示到键盘之上。

使用起来也非常简单:使用KeyboardAvoidingView 包裹我们想要弹起的组件即可。

    return (
        <View>
            <KeyboardAvoidingView  behavior={Platform.OS == "ios" ? "padding" : "height"}>
                <ScrollView ref={scrollRef}>
                    <Text style={styles.header}>Header</Text>
                    <TextInput placeholder="Username 1" style={styles.textInput} />
                    <TextInput placeholder="Username 2" style={styles.textInput} />
                    <TextInput placeholder="Username 3" style={styles.textInput} />
                    <TextInput placeholder="Username 4" style={styles.textInput} />
                    <TextInput placeholder="Username 5" style={styles.textInput} />
                    <TextInput placeholder="Username 6" style={styles.textInput} />
                    <TextInput placeholder="Username 7" style={styles.textInput} />
                    <TextInput placeholder="Username 8" style={styles.textInput} />
                    <TextInput placeholder="Username 9" style={styles.textInput} />
                    <View style={styles.btnContainer}>
                        <Button title="Submit" />
                    </View>
                </ScrollView>
            </KeyboardAvoidingView>
        </View>
    );

效果:

normal.gif

可以看到,每次键盘弹起时,ScrollView都会滚动到相应的输入框下。

KeyboardAvoidingView 的注意点

了解了实现原理,接下来我们再回头看下开头的问题。

其实现是基于这样一个场景:由于页面结构都很类似,所以我们封装了一个简单组件MyPage,顶部有导航栏NavBar,同时为了处理安全区域(比如苹果的刘海屏),使用SafeAreaView包裹了一下。大致代码如下:

function MyPage(props: { children: ReactNode }) {
    return <>
        <NavBar></NavBar>
        <SafeAreaView style={{flex:1}}>
            {props.children}
        </SafeAreaView>
    </>
}
// 页面使用
 <MyPage>
    <KeyboardAvoidingView behavior={'padding'}>
        <ScrollView ref={scrollRef}>
            <Text style={styles.header}>Header</Text>
            <TextInput placeholder="Username 1" style={styles.textInput} />
            <TextInput placeholder="Username 2" style={styles.textInput} />
            <TextInput placeholder="Username 3" style={styles.textInput} />
            <TextInput placeholder="Username 4" style={styles.textInput} />
            <TextInput placeholder="Username 5" style={styles.textInput} />
            <TextInput placeholder="Username 6" style={styles.textInput} />
            <TextInput placeholder="Username 7" style={styles.textInput} />
            <TextInput placeholder="Username 8" style={styles.textInput} />
            <TextInput placeholder="Username 9" style={styles.textInput} />
            <View style={styles.btnContainer}>
                <Button title="Submit" />
            </View>
        </ScrollView>
    </KeyboardAvoidingView>
</MyPage>

然后我们聚焦某一个输入框,就复现了开头的问题: video.gif

KeyboardAvoidingView 组件并没有按照我们预期的那样有效果,输入框虽然进行了偏移,但仍然被键盘遮挡着。

这是为什么?与上面正常的示例相比,我们也不过是对其进行了一些包装而已。难不成是因为SafeAreaView包裹了?如果是的话,又是为什么呢?

当我们去除掉SafeAreaView之后,即MyPage是这样的形式:

function MyPage(props: { children: ReactNode }) {
    return <>
        <NavBar></NavBar>
        {/* <SafeAreaView> */}
        {props.children}
        {/* </SafeAreaView> */}
    </>
}

然后发现KeyboardAvoidingView组件又能正常的工作了,看起来就是SafeAreaView影响到了该组件。那么它是怎么影响的呢?是只有SafeAreaView会有这样的影响还是其他的组件也会这样?

为了探究这个问题,首先想到的是不是bottom值计算错误导致的?我在KeyboardAvoidingView组件内部计算bottom的方法里加了一行打印,看看计算的结果:

// 省略部分代码
 async _relativeKeyboardHeight(
    keyboardFrame: KeyboardMetrics,
  ): Promise<number> {
    const keyboardY =
      keyboardFrame.screenY - (this.props.keyboardVerticalOffset ?? 0);
    // Calculate the displacement needed for the view such that it
    // no longer overlaps with the keyboard
    let bottom = Math.max(frame.y + frame.height - keyboardY, 0);
    console.log('KeyboardAvoidingView _relativeKeyboardHeight bottom:', bottom,
      ' frame:', frame, ' keyboardY:', keyboardY,
      'keyboardFrame', keyboardFrame);
    return bottom;
  }

这里的frame就是KeyboardAvoidingView的frame。

添加SafeAreaView之前的:

 LOG  KeyboardAvoidingView _relativeKeyboardHeight bottom: 434  frame: {"height": 852, "width": 393, "x": 0, "y": 98}  keyboardY: 516 keyboardFrame {"height": 336, "screenX": 0, "screenY": 516, "width": 393}

去除之后:

LOG  KeyboardAvoidingView _relativeKeyboardHeight bottom: 302  frame: {"height": 818, "width": 393, "x": 0, "y": 0}  keyboardY: 516 keyboardFrame {"height": 336, "screenX": 0, "screenY": 516, "width": 393}

可以看到两次的bottom值确实不一致。而且两次的值正好差了98,也就是能正常工作时的KeyboardAvoidingViewframey坐标。

好了,到了这里细心的小伙伴已经能分析到原因是什么了(文章里也暗示了一点),那就是坐标系的问题。

上面已经说过了,onLayout获取的frame是先对于父组件的,而获取键盘的Y坐标是相对于屏幕的。而bottom的计算方式为:

Math.max(frame.y + frame.height - keyboardY, 0)

KeyboardAvoidingView没有父组件(或者说父组件就是当前屏幕的时候)时,这里的frame.y就是以屏幕左上角为原点的值,跟键盘是同一个坐标原点,这时候的计算是没有问题的。

而一旦有了一个父组件,这时候获取的frame.y就是以KeyboardAvoidingView父组件为坐标系得到的Y坐标,此时已经跟键盘不是同一个坐标原点了,那么在计算的时候就有可能出错

所以在使用KeyboardAvoidingView组件的时候需要保障其是以屏幕左上角为坐标原点的,如果不是的话就有可能出现问题。

针对这种情况,我们可以通过keyboardVerticalOffset属性设置一些偏移量来修正Y坐标。要想真正知道KeyboardAvoidingView相对于屏幕的Y坐标的话可以通过measure方法,不过很可惜该方法是一个异步方法。

我们当然可以通过该方法获取真正的偏移量之后,再通过setState方法设置keyboardVerticalOffset来达到目的,但是这样会产生一次rebuild,而目的仅仅是为了计算keyboardVerticalOffset。 所以,还是建议在一开始就手动计算好需要的偏移量。

其他

从上面的动图中我们可以看到,当键盘弹起的时候,scrollView会自动滚到该输入框,这并不是KeyboardAvoidingView组件的作用(毕竟它只是改变ScrollView的高度而已),而是RN组件ScrollView自带的效果。 我们以iOS端为例,ScrollView对应的iOS端的组件是RCTScrollView, 在其初始化的时候会注册键盘事件:

    - (instancetype)initWithEventDispatcher:(id<RCTEventDispatcherProtocol>)eventDispatcher
    {
      RCTAssertParam(eventDispatcher);

      if ((self = [super initWithFrame:CGRectZero])) {
        [self _registerKeyboardListener];
        // other code
        ...
      }
      return self;
    }
    // 监听键盘事件
    - (void)_registerKeyboardListener
    {
      [[NSNotificationCenter defaultCenter] addObserver:self
                                               selector:@selector(_keyboardWillChangeFrame:)
                                                   name:UIKeyboardWillChangeFrameNotification
                                                 object:nil];
    }

在监听事件里会获取当前获取焦点的输入框的坐标,并将该做标转换为相对于屏幕(window)的坐标,然后计算出需要滚动的偏移量进行滚动

- (void)_keyboardWillChangeFrame:(NSNotification *)notification
{
  ...
  // 获取键盘显示动画时间
  double duration = [notification.userInfo[UIKeyboardAnimationDurationUserInfoKey] doubleValue];

  UIViewAnimationCurve curve =
      (UIViewAnimationCurve)[notification.userInfo[UIKeyboardAnimationCurveUserInfoKey] unsignedIntegerValue];
  
  //键盘开始/结束位置
  CGRect beginFrame = [notification.userInfo[UIKeyboardFrameBeginUserInfoKey] CGRectValue];
  CGRect endFrame = [notification.userInfo[UIKeyboardFrameEndUserInfoKey] CGRectValue];

  // 获取自身相对于屏幕的位置
  CGPoint absoluteViewOrigin = [self convertPoint:self.bounds.origin toView:nil];
  
  CGFloat scrollViewLowerY = self.inverted ? absoluteViewOrigin.y : absoluteViewOrigin.y + self.bounds.size.height;

  UIEdgeInsets newEdgeInsets = _scrollView.contentInset;
  CGFloat inset = MAX(scrollViewLowerY - endFrame.origin.y, 0);
  if (self.inverted) {
    newEdgeInsets.top = MAX(inset, _contentInset.top);
  } else {
    newEdgeInsets.bottom = MAX(inset, _contentInset.bottom);
  }

  CGPoint newContentOffset = _scrollView.contentOffset;
  self.firstResponderFocus = CGRectNull;

  CGFloat contentDiff = 0;
  if ([[UIApplication sharedApplication] sendAction:@selector(reactUpdateResponderOffsetForScrollView:)
                                                 to:nil
                                               from:self
                                           forEvent:nil]) {
    // 计算当前获取焦点的输入框的最大Y坐标
    CGFloat focusEnd = CGRectGetMaxY(self.firstResponderFocus);
    BOOL didFocusExternalTextField = focusEnd == INFINITY;
    if (!didFocusExternalTextField && focusEnd > endFrame.origin.y) {
      // Text field active region is below visible area with keyboard - update diff to bring into view
      contentDiff = endFrame.origin.y - focusEnd;
    }
  } else if (endFrame.origin.y <= beginFrame.origin.y) {
    // Keyboard opened for other reason
    contentDiff = endFrame.origin.y - beginFrame.origin.y;
  }
  if (self.inverted) {
    newContentOffset.y += contentDiff;
  } else {
    newContentOffset.y -= contentDiff;
  }
   ...
   
// 滚动到输入框位置
  [UIView animateWithDuration:duration
                        delay:0.0
                      options:animationOptionsWithCurve(curve)
                   animations:^{
                     self->_scrollView.contentInset = newEdgeInsets;
                     self->_scrollView.scrollIndicatorInsets = newEdgeInsets;
                     [self scrollToOffset:newContentOffset animated:NO];
                   }
                   completion:nil];
}