tag: [React, React Native, expo, KeyboardAvoidingView]
前言
在编写移动端 App 中, 一个经常处理的问题就是如何避免键盘遮挡页面, 在 H5 中可以通过 scrollIntoView
或者手动设置 scrollTop
的值来处理, 今天让我们把目光聚焦于 React Native, 看看 RN 是如何产生和处理键盘遮挡视图的问题.
本文相关代码 github: github.com/MonchiLin/K…
Android:windowSoftInputMode
Android 自身可以通过 android:windowSoftInputMode
来设置软键盘的方式, 这里就多余的就不做赘述了, 只介绍最常用的两种 adjustResize
和 adjustPan
.
如果你也使用 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 被顶上去了.
Pan 模式
观看 gif 图可以发现, Fixed Header 在输入框也被顶上去了.
总结
对于笔者来说, 最大的区别就在于两者对于键盘弹出时的处理, 除此之外还有一些其他区别, 但是本文的重点不在于此, 如果有兴趣深入了解的小伙伴可以自行搜索 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
, 来避免被键盘遮挡.
聊天框式视图
效果预览, 头部导航栏和底部输入框是固定的, 中间内容区域可滚动.
主要需要注意的是 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
被设置为了键盘的高度.
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 跟随输入框内容区域滚动, 而不是固定
伪代码如下:
RootView
Fixed Header
Logo
KeyboardAvoidingView
ScrollView
若干个输入框
View
注册, 登录按钮
首先是第一个需求, 已经决定了我们不能设置 android:windowSoftInputMode
为 ajustPan
因为这个模式在软键盘弹出时会平移整个视图从而导致 Fixed Header 不被固定
第二个需求已经被 KeyboardAvoidingView 自动处理了.
为了解决第三个需求, 我们将 Logo 移动至 KeyboardAvoidingView/ScrollView 内部即可解决, 即如下伪代码
RootView
Fixed Header
KeyboardAvoidingView
ScrollView
Logo
若干个输入框
View
注册, 登录按钮
它的秘诀在于, KeyboardAvoidingView 只能影响到自身的子元素, 要控制别的元素就要让别的元素成为 KeyboardAvoidingView 的子元素
此时整个页面已经表现的很好了, 但是问题往往不是这么简单, 让我们看看现在的效果
可以发现, 在 iOS 上表现已经很好了, 但是在 Android 上面会将键盘自动顶起来, 要解决这个问题有两种方案
- 将
android:windowSoftInputMode
设置为为 ajustPan, 但是上面提到, ajustPan 会破坏布局, 所以不能使用. - 将
按钮组
也移动到 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
实现并不相同, 这个库会通过计算元素位置, 然后滚动至该元素位置.