如何在 React Native 中实现聊天应用那样的键盘交互
本文分享如何实现聊天应用那样的输入切换交互,包括键盘、操作面板、表情面板的切换。
分析与设计
聊天页面由主界面(含输入栏)、键盘区、表情面板(表情包)、操作面板(工具箱)构成。
键盘、表情包、工具箱都可以使主界面发生位移,而且位移的高度是不一样的。我们需要处理好键盘、表情包、工具箱之间的切换动画,以及主界面相应的位移动画。
我们为每个面板(键盘、表情包、工具箱)定义一个老司机 -- Driver
,老司机带着它所管理的面板(键盘、表情包、工具箱)显示和隐藏,在这个过程中,驱动主界面以合适的动画位移。
接口设计
Driver
的接口设计如下:
export interface DriverState {
// 输入栏底部距离屏幕底部的距离
bottom: number
// 当前带飞的老司机
driver: Driver | undefined
// 设置老司机
setDriver: (driver: Driver | undefined) => void
// 设置主界面的位移动画
setTranslateY: (value: Animated.Value) => void
}
export interface Driver {
// 显示该老司机驱动的面板(键盘、表情包、工具箱)
show: (state: DriverState) => void
// 隐藏该老司机驱动的面板(键盘、表情包、工具箱)
hide: (state: DriverState) => void
// 在显示和隐藏之间切换
toggle: (state: DriverState) => void
// 该老司机驱动的面板是否显示
shown: boolean
// 该老司机驱动的面板的高度
height: number
// 该老司机的名字
name: string
}
基本页面布局
主界面占满整个屏幕,它的 zIndex 为 1。
表情包面板和工具箱面板都是绝对定位,位于屏幕底部,躲藏在主界面身后。
<主界面 style={{ flex: 1, zIdnex: 1 }}>
<ScrollView />
<输入栏>
<TextInput /> <表情按钮 /> <工具箱按钮 />
</输入栏>
</主界面>
<表情包面板 style={{ position: 'absolute' }} />
<工具箱面板 style={{ position: 'absolute' }} />
实现 ViewDriver
我们先来实现表情包和工具箱的 Driver
。表情包和工具箱都是普通的 View
,我们将其命名为 ViewDriver
。
export class ViewDriver implements Driver {
constructor(public name: string) {}
}
我们需要一个动画来驱动表情包和工具箱的显示和隐藏,当显示时,该动画的 y 轴位移为 0 ,当隐藏时,该动画 y 轴位移为表情包和工具箱的高度。
如何获得表情包和工具箱的高度呢?onLayout
事件来帮忙。
export class ViewDriver implements Driver {
private animation = new Animated.Value(0)
height = 0
onLayout = (event: LayoutChangeEvent) => {
// 界面刚创建时,隐藏表情包和工具箱
this.animation.setValue(event.nativeEvent.layout.height)
this.height = event.nativeEvent.layout.height
}
}
当点击输入栏上面的表情 icon 或 + 号按钮时,需要显示或隐藏表情包、工具箱。现在我们来实现这两个方法。
当老司机驱动面板显示时:
- 如果前一个老司机不是它自己,那么就让前一个老司机隐藏它的面板。
- 设置自己为当前老司机。
- 根据自身面板的位移动画计算主界面的位移动画。
- 开始显示动画。
export class ViewDriver implements Driver {
shown = false
show = (state: DriverState) => {
const { bottom, driver, setDriver, setTranslateY } = state
if (driver && driver !== this) {
// 隐藏前一个 driver
driver.hide({ bottom, driver: this, setDriver, setTranslateY })
}
this.shown = true
setDriver(this)
setTranslateY(this.translateY)
Animated.timing(this.animation, {
toValue: 0,
duration: 200,
useNativeDriver: true,
}).start()
}
}
当老司机驱动面板隐藏时:
- 如果前一个老司机就是它自己,那么说明没有其它面板弹出,设置当前老司机为
undefined
。 - 并且根据自己的隐藏动画计算主界面的隐藏动画。
- 开始隐藏动画。
- 如果当前老司机不是它自己,马上隐藏它的面板。
export class ViewDriver implements Driver {
hide = (state: DriverState) => {
const { bottom, driver, setDriver, setTranslateY } = state
this.shown = false
if (driver === this) {
setDriver(undefined)
setTranslateY(this.translateY)
Animated.timing(this.animation, {
toValue: this.height,
duration: 200,
useNativeDriver: true,
}).start()
} else {
this.animation.setValue(this.height)
}
}
}
每个老司机都需要决定两个动画。
其一是它驱动的面板的位移动画。
export class ViewDriver implements Driver {
style = {
transform: [
{
translateY: this.animation,
},
],
}
}
其二是由它决定的主界面的位移动画。
export class ViewDriver implements Driver {
get position() {
// 面板在动画过程中在屏幕上显示的高度
// 当面板完全隐藏时,该高度为 0,面板在 y 轴上的位移为 height
// 当面板完全显示时,该高度为 height,面板在 y 轴上的位移为 0
return this.animation.interpolate({
inputRange: [0, this.height],
outputRange: [this.height, 0],
})
}
private get translateY() {
// 输入栏距屏幕底部的距离 - 表情包或工具箱距屏幕底部的距离
const extraHeight = this.senderBottom - this.viewBottom
return this.position.interpolate({
inputRange: [extraHeight, this.height],
outputRange: [0, extraHeight - this.height],
extrapolate: 'clamp',
}) as Animated.Value
}
}
到此,ViewDriver
的主要实现就完成了。我们来看看使用的姿势:
function KeyboardChat() {
const emoji = useRef(new ViewDriver('emoji')).current
const toolbox = useRef(new ViewDriver('toolbox')).current
const [driver, setDriver] = useState<Driver>()
const [translateY, setTranslateY] = useState(new Animated.Value(0))
const driverState = { bottom, driver, setDriver, setTranslateY }
const mainStyle = {
transform: [
{
translateY: translateY,
},
],
}
return (
<SafeAreaProvider style={styles.provider}>
<Animated.View style={[styles.fill, mainStyle]}>
<ScrollView />
<SenderBar>
<TextInput />
<EmojiButton onPress={() => emoji.toggle(driverState)} />
<ToolboxButton onPress={() => toolbox.toggle(driverState)} />
</SenderBar>
</Animated.View>
<EmojiDashboard
style={[styles.absolute, emoji.style]}
onLayout={emoji.onLayout}
/>
<ToolboxDashboard
style={[styles.absolute, toolbox.style]}
onLayout={toolbox.onLayout}
/>
</SafeAreaProvider>
)
}
可以看到,主要就是构造了 ViewDriver
的两个实例,然后就是各种绑定。
实现 KeyboardDriver
键盘的显示和隐藏动画,并不受开发者控制,我们可以做的只是监听。因此需要一个观察者告诉我们,键盘的高度,以及键盘显示和隐藏过程中位置的变化。
keyboard-insets 就是这样一个观察者。
我们使用 keyboard-insets 提供的 KeyboardInsetsView
来包裹 TextInput
,监听键盘事件。
修改主界面布局如下:
<SafeAreaProvider style={styles.provider}>
<Animated.View style={[styles.fill, mainStyle]}>
<ScrollView />
<KeyboardInsetsView onKeyboard={?}>
<TextInput />
<EmojiButton onPress={() => emoji.toggle(driverState)} />
<ToolboxButton onPress={() => toolbox.toggle(driverState)} />
</KeyboardInsetsView>
</Animated.View>
</SafeAreaProvider>
KeyboardInsetsView
有一个 onKeyboard
属性,可用来监听键盘事件,它的签名如下
interface KeyboardState {
height: number // 键盘的高度,不会因为键盘隐藏而变为 0
shown: boolean // 当键盘将隐已隐时,这个值为 false;当键盘将显已显时,这个值为 true
transitioning: boolean // 键盘是否正在显示或隐藏
position: Animated.Value // 键盘的位置,从 0 到 height,可以用来实现动画效果
}
interface KeyboardInsetsViewProps {
onKeyboard?: (status: KeyboardState) => void
}
接下来我们逐一实现 KeyboardDriver
的各个方法。
import { Keyboard } from 'react-native'
export class KeyboardDriver implements Driver {
constructor(private inputRef: React.RefObject<TextInput>) {}
show = () => {
this.inputRef.current?.focus()
}
hide = () => {
Keyboard.dismiss()
}
toggle = () => {
this.shown ? this.hide() : this.show()
}
}
KeyboardDriver
的 show
和 hide
方法,分别调用 TextInput
的 focus
和 Keyboard.dismiss
方法。非常简单,没有多余的动作,这是因为键盘的显示和隐藏分别有两条路径。
点击 TextInput
或者调用 TextInput.focus
也就是 KeyboardDriver 的 show
方法,会触发键盘的显示。
点击 ScrollView
或者调用 Keyboard.dismiss
也就是 KeyboardDriver 的 hide
方法,会触发键盘的隐藏。
不管哪种方式,都会触发 KeyboardInsetsView
的 onKeyboard
回调。因此 onKeyboard
回调才是唯一数据源,才是我们处理键盘动画的唯一依据。
下面,我们在该回调中,实现主要逻辑。
export class KeyboardDriver implements Driver {
private position = new Animated.Value(0)
name = 'keyboard'
shown = false
height = 0
createCallback = (state: DriverState) => {
return (keyboard: KeyboardState) => {
const { shown, height, position } = keyboard
const { bottom, driver, setDriver, setTranslateY } = state
this.height = height
this.position = position
// 显示逻辑
if (shown) {
this.shown = true
if (driver && driver !== this) {
// 隐藏前一个 driver
driver.hide({ bottom, driver: this, setDriver, setTranslateY })
}
setDriver(this)
setTranslateY(this.translateY)
}
// 隐藏逻辑
if (!shown) {
this.shown = false
if (driver === this) {
setDriver(undefined)
setTranslateY(this.translateY)
}
}
}
}
}
createCallback
是一个高阶函数,它接收一个 DriverState
对象,返回一个 onKeyboard
回调函数。
键盘的显示和隐藏逻辑,和表情、工具箱的显示和隐藏逻辑基本一致。
KeyboardDriver
也需要通过 setTranslateY
来设置主界面的位移动画。
export class KeyboardDriver implements Driver {
private get translateY() {
// senderBottom 是输入栏距屏幕底部的距离
const extraHeight = this.senderBottom
return this.position.interpolate({
inputRange: [extraHeight, this.height],
outputRange: [0, extraHeight - this.height],
extrapolate: 'clamp',
}) as Animated.Value
}
}
至此,KeyboardDriver
的主要实现就完成了。接下来,只需要把它的实例和 UI 绑定即可。
function KeyboardChat() {
const inputRef = useRef<TextInput>(null)
const keyboard = useRef(new KeyboardDriver(inputRef)).current
const driverState = { bottom, driver, setDriver, setTranslateY }
const mainStyle = {
transform: [
{
translateY: translateY,
},
],
}
return (
<SafeAreaProvider style={styles.provider}>
<Animated.View style={[styles.fill, mainStyle]}>
<ScrollView />
<KeyboardInsetsView onKeyboard={keyboard.createCallback(driverState)}>
<TextInput ref={inputRef} />
<EmojiButton onPress={() => (emoji.shown ? keyboard.show() : emoji.show(driverState))} />
<ToolboxButton onPress={() => toolbox.toggle(driverState)} />
</KeyboardInsetsView>
</Animated.View>
</SafeAreaProvider>
)
}
示例
这里有一个示例,供你参考。