RN中处理键盘一般使用官方提供的组件KeyboardAvoidingView来避免内容被遮挡,而且使用也比较简单。但有时候它并不能按照我们预期的那样工作,比如:
可以看到其并没有将聚焦的输入框给显示出来,还是遮挡了部分内容。这是为什么呢?
要了解其原因,我们需要简单了解一下其实现原理。
KeyboardAvoidingView 原理
通过查看KeyboardAvoidingView源码发现,其实现原理也比较简单:KeyboardAvoidingView
会通过View
包裹children
组件,然后监听键盘事件,在键盘显示的时候获取键盘的高度和坐标,然后再根据behavior
属性,设置View
的paddingBottom
或者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
,通过设置该View
的bottom
的方式让children
整体显示到键盘之上
而且这里会通过onLayout
事件拿到children
的frame
(大小和位置,在后续计算View
的bottom
时会用到)
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),
];
}
}
在组件挂载之后就会监听键盘事件,在监听事件里会计算 View
的 bottom
值:
_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
里包含:
- duration: 动画时长,单位毫秒
- easing: 键盘动画效果
- endCoordinates: 键盘弹出后的坐标信息
- screenX: 键盘的X坐标
- screenY: 键盘Y坐标(相对于屏幕的)
- width: 键盘宽度
- height: 键盘高度
- 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
回调里获取View
的frame
, 在键盘弹起之后,获取键盘的Y
坐标,通过View
的最大Y
坐标(frame.y + frame.height
) 与键盘的Y
坐标进行相减,得到View
应该设置的bottom
值。
这样如果键盘遮挡了View
,View
会根据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>
);
效果:
可以看到,每次键盘弹起时,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>
然后我们聚焦某一个输入框,就复现了开头的问题:
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,也就是能正常工作时的KeyboardAvoidingView
的frame
的y
坐标。
好了,到了这里细心的小伙伴已经能分析到原因是什么了(文章里也暗示了一点),那就是坐标系的问题。
上面已经说过了,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];
}