如何在 React Native 中处理键盘遮挡

8,777 阅读7分钟

tag: [React, React Native, expo, KeyboardAvoidingView]

前言

在编写移动端 App 中, 一个经常处理的问题就是如何避免键盘遮挡页面, 在 H5 中可以通过 scrollIntoView或者手动设置 scrollTop的值来处理, 今天让我们把目光聚焦于 React Native, 看看 RN 是如何产生和处理键盘遮挡视图的问题.

本文相关代码 github: github.com/MonchiLin/K…

Android:windowSoftInputMode

Android 自身可以通过 android:windowSoftInputMode来设置软键盘的方式, 这里就多余的就不做赘述了, 只介绍最常用的两种 adjustResizeadjustPan. 如果你也使用 expo, 可以通过修改此选项来检验结果. docs.expo.dev/versions/la… 测试代码如下:

import { SafeAreaView, ScrollView, Text, TextInput, View } from 'react-native';

function Boxes() {
  return <>
    {
      [...Array(30).keys()]
        .map(i => {
          return <View
            key={i}
            style={{
              width: "100%",
              backgroundColor: i % 2 === 0 ? "#b7b7b7" : "#828ebe",
              height: 40,
              justifyContent: "center",
              alignItems: "center"
            }}>
            <TextInput placeholder={"Input" + '-' + i}/>
          </View>;
        })
    }
  </>;
}

export default function ResizeVSPan() {
  return <SafeAreaView style={{ flex: 1 }}>
    <View style={{ height: 40, justifyContent: "center", alignItems: "center" }}>
      <Text>Fixed Header</Text>
    </View>
    <ScrollView>
      <View style={{ backgroundColor: "#282fbe", height: 40, justifyContent: "center", alignItems: "center" }}>
        <Text>Inner Header</Text>
      </View>
      <Boxes/>
    </ScrollView>
  </SafeAreaView>;
}

Fixed Header: 始终固定于屏幕顶部 Inner Header: 在内容区域滚动时跟随滚动

Resize 模式

观看 gif 图可以发现, Fixed Header 在输入框弹出时是固定的, 只有 Inner Header 被顶上去了. resize_mode.gif

Pan 模式

观看 gif 图可以发现, Fixed Header 在输入框也被顶上去了. pan_mode.gif

总结

对于笔者来说, 最大的区别就在于两者对于键盘弹出时的处理, 除此之外还有一些其他区别, 但是本文的重点不在于此, 如果有兴趣深入了解的小伙伴可以自行搜索 android windowSoftInputMode关键词. iOS 并没有此选项, 要依靠 KeyboardAvoidingView组件, 所以大家可能在 google 如何解决才会看到类似的回答:

<KeyboardAvoidingView behavior={Platform.select({ ios: "padding", default: undefined })} style={{ flex: 1 }}>

笔者当初看到这个解决方案的时候也觉得很困惑(为什么 android 传 undefined, 参见源码解析和 windowSoftInputMode 用法), 这次深入研究后才明白其本质.

KeyboardAvoidingView 源码解析

/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
* @flow strict-local
*/

import Keyboard from './Keyboard';
import LayoutAnimation from '../../LayoutAnimation/LayoutAnimation';
import Platform from '../../Utilities/Platform';
import * as React from 'react';
import StyleSheet from '../../StyleSheet/StyleSheet';
import View from '../View/View';

import type {ViewStyleProp} from '../../StyleSheet/StyleSheet';
import {type EventSubscription} from '../../vendor/emitter/EventEmitter';
import type {
  ViewProps,
    ViewLayout,
    ViewLayoutEvent,
} from '../View/ViewPropTypes';
import type {KeyboardEvent, KeyboardEventCoordinates} from './Keyboard';

type Props = $ReadOnly<{|
                        ...ViewProps,
                        
                        /**
                        * Specify how to react to the presence of the keyboard.
                        */
                        behavior?: ?('height' | 'position' | 'padding'),
                          
                          /**
                          * Style of the content container when `behavior` is 'position'.
                          */
                          contentContainerStyle?: ?ViewStyleProp,
                            
                            /**
                            * Controls whether this `KeyboardAvoidingView` instance should take effect.
                            * This is useful when more than one is on the screen. Defaults to true.
                            */
                            enabled?: ?boolean,
                            
                            /**
                            * Distance between the top of the user screen and the React Native view. This
                            * may be non-zero in some cases. Defaults to 0.
                            */
                            keyboardVerticalOffset?: number,
                            |}>;
                            
                            type State = {|
                                          bottom: number,
                                          |};

/**
* View that moves out of the way when the keyboard appears by automatically
* adjusting its height, position, or bottom padding.
*/
class KeyboardAvoidingView extends React.Component<Props, State> {
  _frame: ?ViewLayout = null;
  _keyboardEvent: ?KeyboardEvent = null;
  _subscriptions: Array<EventSubscription> = [];
viewRef: {current: React.ElementRef<typeof View> | null, ...};
          _initialFrameHeight: number = 0;
          
          constructor(props: Props) {
            super(props);
            this.state = {bottom: 0};
            this.viewRef = React.createRef();
          }
          
          _relativeKeyboardHeight(keyboardFrame: KeyboardEventCoordinates): number {
            const frame = this._frame;
            if (!frame || !keyboardFrame) {
              return 0;
            }
            
            const keyboardY =
                  keyboardFrame.screenY - (this.props.keyboardVerticalOffset ?? 0);
            
            // Calculate the displacement needed for the view such that it
            // no longer overlaps with the keyboard
            return Math.max(frame.y + frame.height - keyboardY, 0);
          }
          
          _onKeyboardChange = (event: ?KeyboardEvent) => {
            this._keyboardEvent = event;
            this._updateBottomIfNecessary();
          };
          
          _onLayout = (event: ViewLayoutEvent) => {
            const wasFrameNull = this._frame == null;
            this._frame = event.nativeEvent.layout;
            if (!this._initialFrameHeight) {
              // save the initial frame height, before the keyboard is visible
              this._initialFrameHeight = this._frame.height;
            }
            
            if (wasFrameNull) {
              this._updateBottomIfNecessary();
            }
            
            if (this.props.onLayout) {
              this.props.onLayout(event);
            }
          };
          
          _updateBottomIfNecessary = () => {
            if (this._keyboardEvent == null) {
              this.setState({bottom: 0});
              return;
            }
            
            const {duration, easing, endCoordinates} = this._keyboardEvent;
            const height = this._relativeKeyboardHeight(endCoordinates);
            
            if (this.state.bottom === height) {
              return;
            }
            
            if (duration && easing) {
              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',
                },
              });
            }
            this.setState({bottom: height});
          };
          
          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),
                ];
         }
}

componentWillUnmount(): void {
  this._subscriptions.forEach(subscription => {
  subscription.remove();
});
}

render(): React.Node {
  const {
    behavior,
    children,
    contentContainerStyle,
    enabled = true,
    // eslint-disable-next-line no-unused-vars
    keyboardVerticalOffset = 0,
    style,
    onLayout,
    ...props
  } = this.props;
  const bottomHeight = enabled === true ? this.state.bottom : 0;
  switch (behavior) {
    case 'height':
      let heightStyle;
      if (this._frame != null && this.state.bottom > 0) {
        // Note that we only apply a height change when there is keyboard present,
        // i.e. this.state.bottom is greater than 0. If we remove that condition,
        // this.frame.height will never go back to its original value.
        // When height changes, we need to disable flex.
        heightStyle = {
          height: this._initialFrameHeight - bottomHeight,
          flex: 0,
        };
      }
      return (
        <View
          ref={this.viewRef}
          style={StyleSheet.compose(style, heightStyle)}
          onLayout={this._onLayout}
          {...props}>
          {children}
        </View>
      );
      
    case 'position':
      return (
        <View
          ref={this.viewRef}
          style={style}
          onLayout={this._onLayout}
          {...props}>
          <View
            style={StyleSheet.compose(contentContainerStyle, {
              bottom: bottomHeight,
            })}>
            {children}
          </View>
        </View>
      );
      
    case 'padding':
      return (
        <View
          ref={this.viewRef}
          style={StyleSheet.compose(style, {paddingBottom: bottomHeight})}
          onLayout={this._onLayout}
          {...props}>
          {children}
        </View>
      );
      
    default:
      return (
        <View
          ref={this.viewRef}
          onLayout={this._onLayout}
          style={style}
          {...props}>
          {children}
        </View>
      );
  }
}
}

export default KeyboardAvoidingView;

可以看到, KeyboardAvoidingView 的源码还是相当简洁的, 它的实现目标就体现在 behavior参数. 这里提两个源码里的要点

  • keyboardDidShow 事件在 iOS 上还有会有一个 startCoordinates对象, 它可以区分键盘是收起还是展开, 参考, 源码位置.
  • 键盘高度的算法为(仅为笔者推测): 视图(KeyboardAvoidingView 组件)所占用的高度 - 键盘弹出时屏幕可视高度

首先通过 Keyboard.addListener监听键盘弹出和隐藏, 然后通过键盘弹出事件得到 键盘高度最后根据 behavior参数来决定如何处理键盘高度.

对于如何处理键盘高度, 有三种方式

height

这种方式是通过减少 View 的高度, 来避免被键盘遮挡, 实际使用中作用不大, 应用场景也比较少.

position

这种方式是通过设置 View 的 bottom, 来避免被键盘遮挡.

padding

这种方式是通过设置 View 的 paddingBottom, 来避免被键盘遮挡.

聊天框式视图

效果预览, 头部导航栏和底部输入框是固定的, 中间内容区域可滚动.

image.png 主要需要注意的是 KeyboardAvoidingView 应包裹输入框, 但不应包含导航栏, 如果包含导航栏, 则会在键盘显示时将导航栏顶出可视视图外. 注: 笔者的代码并不会, 但是有些情况会, 例如这种情况: Flatlist 包裹了 Header 和 Footer, 不过这又是另一种情况了, 但还是建议大家可以良好的组织视图代码的结构.

<SafeAreaView style={{ flex: 1, }}>
    <KeyboardAvoidingView behavior={Platform.select({ ios: "padding", default: undefined })} style={{ flex: 1 }}>
      <View style={{ flex: 1 }}>
        <FlatList
          ListHeaderComponent={<View style={{ height: 40, justifyContent: "center", alignItems: "center" }}>
            <Text>Fixed Header</Text>
          </View>}
          ListFooterComponent={<View style={{ height: 40, flexDirection: "row" }}>
            <TextInput
              ref={inputRef}
              style={{ backgroundColor: "#dadada", flex: 1, paddingLeft: 20 }}
              placeholder={"input something"}
              onFocus={onFocus}
            />
            <Button onPress={onSendPress} title={"Send"}/>
            <Button onPress={onMorePress} title={"More"}/>
          </View>}
          ref={listRef}
          data={data}
          renderItem={({ item }) => <ItemView item={item}/>}
        />
      </View>

      {<MoreView visible={moreViewVisible}/>}
    </KeyboardAvoidingView>
  </SafeAreaView>

注意 behavior={Platform.select({ ios: "padding", default: undefined })}这里 android 是不处理的, 因为笔者为 Android 设置了 ajustResize, 通过 ajustResize的特性, 使得在键盘弹出时, 视图自动向上滚动的特性来处理键盘遮挡. 如有已经了解了 KeyboardAvoidingView 的原理同学, 不难猜出, 其实在这种场景下键盘弹出时, 视图就变成了这样, 即 paddingBottom被设置为了键盘的高度. image.png

import {
  Animated,
  Button,
  Easing,
  FlatList,
  Keyboard,
  KeyboardAvoidingView,
  Platform,
  Text,
  TextInput,
  View
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import { useEffect, useRef, useState } from "react";

function ItemView({ item }: { item: number }) {
  return <View
    key={item}
    style={{
      width: "100%",
      backgroundColor: item % 2 === 0 ? "#b7b7b7" : "#828ebe",
      height: 40,
      justifyContent: "center",
      alignItems: "center"
    }}>
    <Text>Chart Message {item}</Text>
  </View>;
}

const MoreView = ({ visible }: { visible: boolean }) => {
  const v = useRef(new Animated.Value(visible ? 200 : 0));

  useEffect(() => {
    Animated.timing(v.current, {
      toValue: visible ? 200 : 0,
      useNativeDriver: false,
      duration: 1000,
      easing: Easing.bounce
    }).start();
  }, [visible]);

  return <Animated.View
    style={{ width: "100%", height: v.current, backgroundColor: "#75a67a" }}>

  </Animated.View>;
};

export default function KeyboardAvoidingChart() {
  const insets = useSafeAreaInsets();
  const listRef = useRef<FlatList>(null);
  const inputRef = useRef<TextInput>(null);
  const [data, setData] = useState([...Array(30).keys()]);
  const [moreViewVisible, setMoreViewVisible] = useState(false);

  const onSendPress = () => {
    setData(state => [...state, state.length]);
    setImmediate(() => listRef.current?.scrollToEnd());
  };

  const onFocus = () => {
    setMoreViewVisible(false);
  };

  const onMorePress = () => {
    Keyboard.dismiss();
    setMoreViewVisible(state => !state);
  };

  return (
    <SafeAreaView style={{ flex: 1, }}>
      <View style={{ height: 40, justifyContent: "center", alignItems: "center" }}>
        <Text>Fixed Header</Text>
      </View>
      <KeyboardAvoidingView behavior={Platform.select({ ios: "padding", default: undefined })} style={{ flex: 1 }}>
        <View style={{ flex: 1 }}>
          <FlatList
            ref={listRef}
            data={data}
            renderItem={({ item }) => <ItemView item={item}/>}
          />
        </View>
        <View style={{ height: 40, flexDirection: "row" }}>
          <TextInput
            ref={inputRef}
            style={{ backgroundColor: "#dadada", flex: 1, paddingLeft: 20 }}
            placeholder={"input something"}
            onFocus={onFocus}
          />
          <Button onPress={onSendPress} title={"Send"}/>
          <Button onPress={onMorePress} title={"More"}/>
        </View>
        {<MoreView visible={moreViewVisible}/>}
      </KeyboardAvoidingView>
    </SafeAreaView>
  );
}

登录页面视图

我们的页面布局大概是这样

需求有几点 一. Fixed Header 任何时候都固定在顶部 二. 当弹出软键盘时, 输入框不要被软键盘遮挡 三. 当弹出软键盘时, Logo 跟随输入框内容区域滚动, 而不是固定 image.png 伪代码如下:

RootView
  Fixed Header
  Logo
  KeyboardAvoidingView
    ScrollView
      若干个输入框
  View
    注册, 登录按钮

首先是第一个需求, 已经决定了我们不能设置 android:windowSoftInputModeajustPan因为这个模式在软键盘弹出时会平移整个视图从而导致 Fixed Header 不被固定 第二个需求已经被 KeyboardAvoidingView 自动处理了. 为了解决第三个需求, 我们将 Logo 移动至 KeyboardAvoidingView/ScrollView 内部即可解决, 即如下伪代码

RootView
  Fixed Header
  KeyboardAvoidingView
    ScrollView
      Logo
      若干个输入框
  View
    注册, 登录按钮

它的秘诀在于, KeyboardAvoidingView 只能影响到自身的子元素, 要控制别的元素就要让别的元素成为 KeyboardAvoidingView 的子元素 此时整个页面已经表现的很好了, 但是问题往往不是这么简单, 让我们看看现在的效果 sign-up-1.gif 可以发现, 在 iOS 上表现已经很好了, 但是在 Android 上面会将键盘自动顶起来, 要解决这个问题有两种方案

  1. android:windowSoftInputMode设置为为 ajustPan, 但是上面提到, ajustPan 会破坏布局, 所以不能使用.
  2. 按钮组也移动到 KeyboardAvoidingView/ScrollView 内部, 其原理也是依赖 ajustResize 会自动滚动视图的特性.

最终结构如下:

RootView
  Fixed Header
  KeyboardAvoidingView
    ScrollView
      Logo
      若干个输入框
      View
        注册, 登录按钮

源码分享

import { Button, KeyboardAvoidingView, Platform, SafeAreaView, ScrollView, Text, TextInput, View } from 'react-native';

function StyledInput() {
  return <TextInput
    style={{
      height: 60,
      marginBottom: 20,
      marginTop: 20,
      borderStyle: "solid",
      borderWidth: 1,
      borderColor: "#919191"
    }}
    placeholder={"please enter ..."}/>;
}

function Logo() {
  return <View
    style={{ width: 100, height: 100, backgroundColor: "#3f69d2", justifyContent: "center", alignItems: "center" }}>
    <Text>Logo</Text>
  </View>;
}

export default function SignUp() {
  return <SafeAreaView style={{ flex: 1, alignItems: "center" }}>
    <View style={{ height: 40, justifyContent: "center", alignItems: "center" }}>
      <Text>Sign Up Header</Text>
    </View>
    <KeyboardAvoidingView behavior={Platform.select({ ios: "padding", default: undefined })} style={{ flex: 1 }}>
      <ScrollView>
        <Logo/>
        <StyledInput/>
        <StyledInput/>
        <StyledInput/>
        <StyledInput/>
        <StyledInput/>
        <StyledInput/>
        <View style={{ flexDirection: "row", width: "100%" }}>
          <Button title={"Sign Up"}/>
          <Button title={"Sign In"}/>
        </View>
      </ScrollView>
    </KeyboardAvoidingView>
  </SafeAreaView>;
}

总结

KeyboardAvoiding + ScrollView 已经为我们解决了大多数问题, 但是实际使用仍然有些问题, 这里笔者推荐大家直接使用 react-native-keyboard-aware-scroll-view 来一劳永逸的解决大多数问题, 它的实现和官方的KeyboardAvoiding实现并不相同, 这个库会通过计算元素位置, 然后滚动至该元素位置.