如何在 React Native 中实现聊天应用那样的键盘交互

本文分享如何实现聊天应用那样的输入切换交互,包括键盘、操作面板、表情面板的切换。

README-2023-02-18-21-36-20

分析与设计

聊天页面由主界面(含输入栏)、键盘区、表情面板(表情包)、操作面板(工具箱)构成。

键盘、表情包、工具箱都可以使主界面发生位移,而且位移的高度是不一样的。我们需要处理好键盘、表情包、工具箱之间的切换动画,以及主界面相应的位移动画。

我们为每个面板(键盘、表情包、工具箱)定义一个老司机 -- 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()
  }
}

KeyboardDrivershowhide 方法,分别调用 TextInputfocusKeyboard.dismiss 方法。非常简单,没有多余的动作,这是因为键盘的显示和隐藏分别有两条路径。

点击 TextInput 或者调用 TextInput.focus 也就是 KeyboardDriver 的 show 方法,会触发键盘的显示。

点击 ScrollView 或者调用 Keyboard.dismiss 也就是 KeyboardDriver 的 hide 方法,会触发键盘的隐藏。

不管哪种方式,都会触发 KeyboardInsetsViewonKeyboard 回调。因此 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,供你参考。

上次更新: