如何在 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-insetsopen in new window 就是这样一个观察者。
我们使用 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> ) }
示例
这里有一个示例open in new window,供你参考。