如何在 React Native 中响应键盘开启和关闭
本文分享如何在 React Native 中构建像聊天应用那样的键盘辅助视图。
将要实现的效果如下,你可能认为它非常简单,但是做过的小伙伴都知道,想要处理好键盘和附属视图的交互,是非常困难的。

由于平台的差异性,iOS 和 Android 的实现方式是不一样的。虽然实现方式不一样,但作者保证最后的使用方式一致。
UI 层级
上图中页面结构如下:
function KeyboardDemo() {
const { height, showsActions, onPress, onFocus, onBlur, onTouch } = useKeyboard()
return (
<View style={styles.container}>
<FlatList
contentContainerStyle={styles.content}
keyboardDismissMode="on-drag"
onTouchEndCapture={onTouch}
data={chats}
renderItem={renderItem}
keyExtractor={(item) => item}
inverted
/>
<View style={styles.accessary}>
<ChatInput onPress={onPress} onFocus={onFocus} onBlur={onBlur} />
<Animated.View style={{ height }}>
{showsActions && <ActionBoard />}
</Animated.View>
</View>
</View>
)
}
我们自定义了一个名为 useKeyboard
的 Hook,它返回一个动画值 height
,以及若干回调函数,用于处理键盘和操作面板(ActionBoard)的显示和隐藏。
FlatList
设置了 inverted
属性,这样它就会自底向上渲染。onTouch
事件用于隐藏键盘和操作面板。
View[style=accessary]
是辅助视图的容器,它由两部分组成。一部分是永远显示在键盘之上的输入框和它的辅助按钮,另一部分是高度可变的,可能会被键盘遮盖的操作面板和它的容器。
ChatInput
是一个输入框和一个按钮,onPress
是按钮的点击事件,它会显示操作面板;onFocus
和 onBlur
是输入框的事件,它们用于监听键盘的显示和隐藏,同时,onFocus
也会隐藏操作面板。
height
是一个动画值,当键盘弹出时,它的数值是键盘的高度,当键盘收起时,它的数值是底部安全距离。当操作面板显示时,height
的数值是操作面板的高度加上底部安全距离。
下面我们先来看下,在 iOS,是如何实现 useKeyboard
的。
iOS 实现
iOS 的键盘弹出来之前,会先发布一个通知,告诉开发者键盘将要弹出,键盘的高度是多少,动画曲线、动画时间是多少等等。
同样,在键盘收起之前,也会先发布一个通知。开发者根据这些信息,就可以编写优雅的动画,让界面无缝地跟随键盘开启和关闭。
不过,这是 iOS 原生开发能做到的事情。在 React Native,由于桥的异步性,尽管也有通知,但是已经不能保证界面的移动能跟得上键盘的节奏了。
首先监听键盘将要显示和隐藏事件,获得键盘的高度,这个高度将会用来计算 height
的动画值。
// useKeyboard.ts
const [showsActions, setShowsActions] = useState(false)
const [showsKeyboard, setShowsKeyboard] = useState(false)
const height = useRef(new Animated.Value(getBottomSpace())).current
const [pendingHeight, setPendingHeight] = useState(0)
useEffect(() => {
const substitutions: EmitterSubscription[] = []
substitutions.push(
Keyboard.addListener("keyboardWillShow", ({ endCoordinates }) => {
setPendingHeight(endCoordinates.height)
})
)
substitutions.push(
Keyboard.addListener("keyboardWillHide", () => {
setPendingHeight(0)
})
)
return () => substitutions.forEach((sub) => sub.remove())
}, [])
当点击输入框,也就是输入框获得焦点时,我们需要隐藏操作面板,同时将键盘标志为显示。
const onFocus = () => {
setShowsActions(false)
setShowsKeyboard(true)
}
当输入框失去焦点时,我们需要将键盘标志为隐藏。
const onBlur = () => {
setShowsKeyboard(false)
}
当点击输入框旁边的 + 号按钮时,我们需要显示操作面板,同时隐藏键盘,这会使的输入框失去焦点。
// 操作面板高度
const ACTION_BOARD_HEIGHT = 168
const onPress = () => {
Keyboard.dismiss()
setPendingHeight(ACTION_BOARD_HEIGHT)
setShowsActions(true)
}
当点击聊天信息列表时,需要隐藏键盘,同时隐藏操作面板。
const onTouch = () => {
if (showsActions) {
setPendingHeight(0)
}
setShowsActions(false)
setShowsKeyboard(false)
}
最后,计算 height
的动画值。height
的默认值是底部安全距离,如果显示键盘,就是键盘的高度,如果显示操作面板,就是操作面板的高度加上底部安全距离。
import { getBottomSpace } from "react-native-iphone-x-helper"
useEffect(() => {
height.stopAnimation()
let to = getBottomSpace()
if (showsKeyboard) {
to = pendingHeight
}
if (showsActions) {
to = ACTION_BOARD_HEIGHT + getBottomSpace()
}
Animated.timing(height, {
toValue: to,
duration: 250,
easing: Easing.bezier(0.4, 0, 0.2, 1),
useNativeDriver: false,
}).start()
}, [pendingHeight, showsActions, showsKeyboard, height])
这里使用 Animated.timing()
来创建动画。duration
是 250,这是键盘显示和隐藏的默认时间。easing
使用了一个自定义的贝塞尔曲线,这接近键盘动画的曲线,由于桥的异步性等种种原因,我们无法使用键盘的动画曲线。useNativeDriver
设置为 false
,是因为布局属性不支持原生驱动。
以上就是 iOS 的实现了,总体还是比较简单的。

Android 实现
Android 的键盘是非常难以处理的,这篇文章讲述了艰难的踩坑过程。
Andriod 和 iOS 的键盘机制是不一样的。iOS 的键盘会永远遮盖界面,并发出通知来预警,开发者通过预警来协调界面,可以做到非常优雅的界面切换效果。Android 的键盘通过 windowSoftInputMode
来控制,或者压缩界面,或者遮盖界面,但不会预警,因此 Android 的键盘无论是显示还是隐藏,过渡效果都比较生硬。
在我们的 AndroidManifest 文件中,我们可以为 Activity 设置 windowSoftInputMode
。
<activity
android:name=".MainActivity"
android:windowSoftInputMode="adjustResize">
...
</activity>
adjustResize
会调整页面大小,屏幕被分割成上下两部分,上半部分属于 App,下半部分属于键盘。

adjustNothing
则不会调整页面大小,键盘会覆盖整个屏幕。

adjustPan
也不会调整页面大小,但它会移动页面,这会导致有些期待保留的视图被移出了屏幕,譬如顶部的导航栏。

当 windowSoftInputMode
为 adjustResize
或 adjustPan
时,React Native 会在键盘显示或隐藏完成后,发布相关事件。因为并不是预警事件,所以开发者无法像 iOS 那样优雅地协调界面切换。
当 windowSoftInputMode
为 adjustNothing
时,React Native 并不会发布相关事件。
从以上截图来看,当键盘模式为 adjustResize
时,页面效果是我们想要的。
我们先来看 Android 如何实现 useKeyboard
:
// useKeyboard.andriod.ts
import { useEffect, useState } from "react"
import { Keyboard } from "react-native"
export default function useKeyboard() {
const [showsActions, setShowsActions] = useState(false)
const [showsKeyboard, setShowsKeyboard] = useState(false)
const height = "auto"
const onPress = () => {
Keyboard.dismiss()
setShowsActions(true)
}
const onBlur = () => {
setShowsKeyboard(false)
}
const onFocus = () => {
setShowsActions(false)
setShowsKeyboard(true)
}
const onTouch = () => {
setShowsActions(false)
setShowsKeyboard(false)
}
return {
height,
showsKeyboard,
showsActions,
onPress,
onBlur,
onFocus,
onTouch,
}
}
以上的代码基本可以工作了,其中 height
不再是一个动画值,而是 auto
。
但是,当键盘显示时,如果试图切换到操作面板,则会发生界面闪烁现象,我想这是大多数开发者都会遇到的问题。

这是怎么回事呢?查看 onPress
的实现代码,就简简单单两行,隐藏键盘和显示操作面板。
const onPress = () => {
Keyboard.dismiss()
setShowsActions(true)
}
由于 softInputMode
为 adjustResize
,当调用 Keyboard.dismiss()
时,键盘还没有完全隐藏,页面也还没来得及由半屏恢复到全屏,有那么一瞬间,页面长下面这个样子,这就发生了闪烁。

作者想到的办法就是调整键盘模式。当打开操作面板时,把 windowSoftInputMode
设置为 adjustNothing
。这就需要通过编写原生模块来动态更改 windowSoftInputMode
。
Java 代码并不复杂:
@ReactMethod
public void setAdjustNothing(Callback callback) {
Activity activity = getCurrentActivity();
activity.runOnUiThread(() -> {
activity.getWindow()
.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING);
callback.invoke(null, "");
});
}
@ReactMethod
public void setAdjustResize(Callback callback) {
Activity activity = getCurrentActivity();
activity.runOnUiThread(() -> {
activity.getWindow()
.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE);
callback.invoke(null, "");
});
}
根据编写 React Native 原生模块的若干提示,在 JavaScript 这边,把 callback 转换成 promise。
现在来修改 onPress
,如下:
const onPress = async () => {
Keyboard.dismiss()
await SoftInputMode.setAdjustNothing()
setShowsActions(true)
}
发现还是有较大几率会出现闪烁现象。于是使用 requestAnimationFrame()
来拯救,待页面恢复至全屏,才打开操作面板。
const onPress = async () => {
Keyboard.dismiss()
await SoftInputMode.setAdjustNothing()
requestAnimationFrame(() => {
setShowsActions(true)
})
}
这样在 debug 环境下,仅有极小概率出现闪烁现象,而在 release 环境下,作者还没有遇到过。
同样需要修改 onFocus
,在输入框获得焦点时,将 windowSoftInputMode
设置为 adjustResize
。
const onFocus = async () => {
await SoftInputMode.setAdjustResize()
requestAnimationFrame(() => {
setShowsActions(false)
setShowsKeyboard(true)
})
}
限制和未来
尽管我们做了不少努力,由于桥的异步性,在 iOS 平台,无法做到原生那样,让界面无缝地跟随键盘开启和关闭。
Android 11 对 WindowInsets API 作了大量改进,也像 iOS 那样支持在键盘打开和关闭时创建无缝转换。
因此,要实现 Keyboard Accessary View 和键盘之间的无缝转换,最优雅的方法就是编写一个原生组件。作者有时间或许会来做这件事。
示例
这里有一个示例,供你参考。
转载自:https://juejin.cn/post/7124974714455326734