如何在 React Native 中响应键盘开启和关闭

1,970 阅读7分钟

本文分享如何在 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 是按钮的点击事件,它会显示操作面板;onFocusonBlur 是输入框的事件,它们用于监听键盘的显示和隐藏,同时,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 也不会调整页面大小,但它会移动页面,这会导致有些期待保留的视图被移出了屏幕,譬如顶部的导航栏。

windowSoftInputModeadjustResizeadjustPan 时,React Native 会在键盘显示或隐藏完成后,发布相关事件。因为并不是预警事件,所以开发者无法像 iOS 那样优雅地协调界面切换。

windowSoftInputModeadjustNothing 时,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)
}

由于 softInputModeadjustResize,当调用 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 和键盘之间的无缝转换,最优雅的方法就是编写一个原生组件。作者有时间或许会来做这件事。

示例

这里有一个示例,供你参考。