likes
comments
collection
share

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

作者站长头像
站长
· 阅读数 44

本文分享如何在 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 的实现了,总体还是比较简单的。

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

Android 实现

Android 的键盘是非常难以处理的,这篇文章讲述了艰难的踩坑过程。

Andriod 和 iOS 的键盘机制是不一样的。iOS 的键盘会永远遮盖界面,并发出通知来预警,开发者通过预警来协调界面,可以做到非常优雅的界面切换效果。Android 的键盘通过 windowSoftInputMode 来控制,或者压缩界面,或者遮盖界面,但不会预警,因此 Android 的键盘无论是显示还是隐藏,过渡效果都比较生硬。

在我们的 AndroidManifest 文件中,我们可以为 Activity 设置 windowSoftInputMode

<activity
    android:name=".MainActivity"
    android:windowSoftInputMode="adjustResize">
    ...
</activity>

adjustResize 会调整页面大小,屏幕被分割成上下两部分,上半部分属于 App,下半部分属于键盘。

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

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

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

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

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

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

但是,当键盘显示时,如果试图切换到操作面板,则会发生界面闪烁现象,我想这是大多数开发者都会遇到的问题。

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

这是怎么回事呢?查看 onPress 的实现代码,就简简单单两行,隐藏键盘和显示操作面板。

const onPress = () => {
  Keyboard.dismiss()
  setShowsActions(true)
}

由于 softInputModeadjustResize,当调用 Keyboard.dismiss() 时,键盘还没有完全隐藏,页面也还没来得及由半屏恢复到全屏,有那么一瞬间,页面长下面这个样子,这就发生了闪烁。

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

作者想到的办法就是调整键盘模式。当打开操作面板时,把 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
评论
请登录