使用 React Native 核心组件的若干提示和技巧

本文分享作者在使用 React Native 核心组件open in new window时积累的一些经验。

❗ 本文不能作为这些 UI 组件的标准文档,作为初学者,你应该先查看官方文档。

Text

Textopen in new window 用于显示文本,支持嵌套,点击事件等。

行高

大多数情况下,需要设置 TextlineHeight 样式属性。只有这样,才能按照设计稿的要求将 Text 放到正确的位置上。

当前的 UI 设计软件,通常都会给一个默认的行高。如果你的设计稿是没有行高的,请你的设计师升级或更换设计软件。

记住,除非你的文本在盒子里垂直居中,否则一定要设置 lineHeight 样式属性。

嵌套

Text 是可以嵌套的,这对于需要在同一行文字中,设置不同的大小颜色等,是非常有用的。内层文字会继承外层文字的样式。

<Text style={{ lineHeight: 20, fontSize: 14, color: '#333333' }}>
  请同意<Text style={{ color: '#448AFF' }}>《我们的协议》</Text>
</Text>

onPress

Text 也可以响应 onPress 事件,记得将 suppressHighlighting 设置为 true,否则在 iOS 上会有一个比较丑的点击高亮效果。

<Text style={{ lineHeight: 20, fontSize: 14, color: "#333333" }}>
  请同意
  <Text style={{ color: "#448AFF" }} suppressHighlighting onPress={() => {
    console.log("点击了");
  }}>《我们的协议》</Text>
  </Text>
</Text>

ellipsizeMode

如果设置了 numberOfLines,超出的文字会被截断,并且会显三个点。

这是 ellipsizeModeopen in new window 属性在发生作用,它的默认值是 tail,即在文字的末尾显示 ...

adjustsFontSizeToFit

有些时候,我们希望能够显示完整的一行文字,而不是被截断,因为文字过长是极少数情况。这时候,可以使用 adjustsFontSizeToFit 属性,自动缩小字体以适应给定的样式约束。

ui-tips-2022-07-21-12-09-20

fontWeight

fontWeight 属性可以设置粗体。但是设置为数值时,只对 iOS 生效。

吞字现象

当你发现,设置了 fontWeight: 'bold',但在部分 Android 手机上,却看不到粗体的效果,那么很有可能发生了吞字现象。

此时,请查看 如何在 React Native 中处理 Android 手机吞字问题

View

Viewopen in new window 通常作为容器使用。熟练使用 flexbox 布局,可以让你的 UI 层级更加简洁。

宽高

宽高可以是固定数值,可以是百分比,也可以使用 flex 来控制。

如果一个 View 没有指定宽高,默认值就是 auto,将根据其子组件来计算宽高。

ui-tips-2022-07-21-15-27-03

如果设置了 flex 属性,但是其父组件没有设置固定宽高,则其父组件的尺寸为 0,并且 flex 子组件将不可见。

ui-tips-2022-07-21-15-25-39

flex 只能填满主轴。如果希望填满交叉轴,则需要在其父组件中设置 alignItems: 'stretch',这是默认值;或者在 flex 子组件中设置 alignSelf: 'stretch'width: '100%'

ui-tips-2022-07-21-15-18-31

为什么红色背景的 View,其宽度撑满屏幕?因为它的父组件的 alignItems 样式属性为默认值 stretch

为什么黄色背景的 View,其宽度等于子组件的宽度?因为它的父组件的 alignItems 样式属性设置为了 center

onLayout

使用这个回调,可以获取 View 的位置和大小。

@react-native-community/hooks 这个包提供了一个方便的 Hook 来帮助我们获取这些信息。

import { useLayout } from '@react-native-community/hooks'

function MyComponent() {
  const { x, y, width, height, onLayout } = useLayout()
  return <View onLayout={onLayout} />
}

可以在 React Native 可复用 UI 小技巧:分离布局组件和状态组件 一文中,看到 useLayout 的应用。

1px 分割线

在 React Native,也会遭遇 1px 分割线的问题。当设计师想要添加 1px 分割线时,TA 可能会在设计稿中使用 0.5 这个数值。但如果我们照搬这个数值,则有可能出现粗细不一的分割线。

因为设计师是使用设备独立像素来设计的,譬如以 iPhone X 为基准,页面大小是 375 * 812。0.5 这个数值,在 iPhone X 上就是 1.5 像素,设备面对这种非整数像素,会使用抗锯齿技术,因此有可能会得到一条既粗又模糊的线。

如果你的设计师不是基于设备独立像素来设计的,请纠正 TA 的行为。

React Native 提供了一个常量来解决这个问题:StyleSheet.hairlineWidth。这个常数将总是一个整数的像素(所以由它定义的线看起来很清晰),并将试图匹配底层平台上细线的标准宽度。

roundToNearestPixel

PixelRatio 工具类有个 roundToNearestPixel 方法,可以将数值转换为最接近的整数像素。

如果你发现两个 View 之间有间隙,总是合不拢,那么可以尝试使用这个方法。

<View
  style={{
    backgroundColor: 'white',
  }}>
  <View
    style={{
      // 50 dp 在某些设备上可能不是整数像素,那么在这种背景鲜明的情况下,就会看到一条缝
      height: PixelRatio.roundToNearestPixel(50),
      backgroundColor: 'coral',
    }}
  />
  <View
    style={{
      backgroundColor: '#ff9f7A',
    }}
  />
</View>

阴影

在 iOS,可以通过 shadowColorshadowOffsetshadowOpacityshadowRadius 这几个样式属性来设置阴影。但除了 shadowColor,其余几个属性在 Android 上都没有效果。如何让这几个属性在 Android 上生效,请查看 如何在 React Native 中实现无侵入式的阴影效果

shadow-box-2022-07-14-00-27-41

collapsable

在 Android,如果一个 View 只有布局属性,没有样式属性,譬如背景颜色,那么它只会用于计算布局,底层不会有和它对应的存在。

有些时候,一个 View 的样式属性可能会从无到有,或者反过来,此时需要确保在原生底层有对应的视图存在,否则可能会出现奇怪的现象,那么将 collapsableopen in new window 设置为 false

pointerEvents

pointerEventsopen in new window 控制 View 是否可以作为触摸事件的目标。设置为 none 可以让触摸事件穿透到底下的视图,如何在 React Native 中实现确认码组件 一文中使用到这种技术。

Button

Buttonopen in new window 具有平台独特样式,仅支持有限的配置,通常不会使用这个组件。

Touchable 系列组件, 譬如 TouchableWithoutFeedbackopen in new windowTouchableOpacityopen in new window 等,也可以用来做按钮。

不过官方推荐使用 Pressableopen in new window

Pressable

为什么我们应该在 React Native 中使用 Pressable?open in new window

  • Pressable 是多合一的可触摸组件

    Pressable 组件具有 Touchable 组件的所有功能。因此,我们可以将所有的 TouchableOpacity、TouchableHighlight、TouchableNativeFeedback 和 TouchableWithoutFeedback 替换为单个组件。

  • Pressable 是一个更广泛和面向未来的组件

    Touchable 在背后使用了 Mixin。简单来说,Mixin 是一种复用代码的技术。但 Facebook 不建议使用它,因为它可能会在未来引起新的问题。

    Pressable 在不改变 Touchable Mixin 固有行为的情况下将其重构为 ES6 类。这就是为什么它是一个更广泛和面向未来的组件。

Pressable 本身可以当作一个容器来使用,这意味着它的子视图被包裹在一个 View 中,这样就可以更好的控制子视图的布局。

如何防止小伙伴们不小心使用了 Touchable 系列组件呢,请参考 如何在 React Native 中限制导入某些组件和模块

hitSlop

有时,按钮太小,不好触摸,那么可以使用 hitSlop 来增加可点击范围。

<Pressable onPress={onPress} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
  <Text>小按钮</Text>
</Pressable>

Image

Imageopen in new window 可以用来显示不同类型的图像,包括网络图像、静态资源、临时本地图像以及来自本地磁盘的图像,例如相机胶卷。

一般会在程序里放置两倍图和三倍图(用 @2x 和 @3x 后缀区分),以适配不同像素密度的屏幕。

图片要放在靠近它使用的地方,不要把所有图片都放在一个 assets 或 images 文件夹里,这样一方面违反了模块化原则,另一方面也不好维护。

Image API

require('./foo.png') 返回一个 number 类型的值,可以认为它是个 id,如果你希望获得它背后所代表的对象,可以使用 Image.resolveAssetSource()

const { uri, width, height } = Image.resolveAssetSource(require('./foo.png'))

本地图片

如果需要引用本地图片,使用以下方式,注意要设置宽高。

<Image source={{ uri: 'app_icon' }} style={{ width: 40, height: 40 }} />

圆角阴影

在 iOS 平台,Image 是支持阴影的。但在 Android 平台,它不支持阴影。

如果要设置圆角阴影,需要将 Image 嵌套在 View 里面,并且都要设置 borderRadius

function Misc() {
  return (
    <View style={styles.container}>
      <View style={styles.card}>
        <Image source={require('./image.png')} style={styles.image} />
      </View>
    </View>
  )
}

const styles = StyleSheet.create({
  image: {
    borderRadius: 8,
  },
  card: {
    backgroundColor: '#fff',
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 0 },
    shadowOpacity: 0.25,
    shadowRadius: 4,
    borderRadius: 8,
  },
})

FastImage

在 Android 平台,Image 底层是由 Fresco 实现的。它有两个不足之处:

  • 加载网络图片时,没有缓存控制,相反,在 iOS 平台上,它会自动缓存图片。

  • 它做了过度优化,当承载页面的 Fragment 将要隐藏时,在执行转场动画的过程中,图片会马上消失,这给人的感觉非常不好。

官方推荐使用社区提供的 FastImage 组件,它提供了一个更好的控制缓存的能力,图片也不会在转场过程中消失。

不过 FastImage 使用时需要指定宽高,即使是通过 require 引入的图片,也需要指定宽高。此外,tintColor 是另外的属性,而不是在 style 里。

<FastImage
  source={checked ? require('./checked.png') : require('./unchecked.png')}
  resizeMode="contain"
  tintColor={checked ? '#448AFF' : '#888888'}
  style={{ width: 14, height: 14 }}
/>

如果希望在底层将 Image 替换为 FastImage,也可以写个 polyfill。以下这个 polyfill 还有些待完善的地方,譬如转发缓存参数等等。

import React from 'react'
import { StyleSheet, Image, ImageProps, ImageResizeMode } from 'react-native'
import FastImage, { FastImageProps, Source } from 'react-native-fast-image'

// @ts-ignore
const __render: any = Image.render

// @ts-ignore
Image.render = function (props: ImageProps, ref: React.RefObject<Image>) {
  const { style, source, resizeMode, ..._props } = props
  let _style = StyleSheet.flatten(style) || {}

  if (typeof source === 'number') {
    const { width, height } = Image.resolveAssetSource(source)
    if (width && height) {
      _style = { width, height, ..._style }
    }
  }

  return React.createElement<any>(FastImage, {
    ..._props,
    style: { ..._style },
    source,
    resizeMode: fastImageResizeMode(resizeMode),
    tintColor: _style.tintColor,
    ref,
  })
}

function fastImageResizeMode(mode: ImageResizeMode = 'cover') {
  switch (mode) {
    case 'contain':
      return FastImage.resizeMode.contain
    case 'stretch':
      return FastImage.resizeMode.stretch
    case 'center':
      return FastImage.resizeMode.center
    case 'cover':
      return FastImage.resizeMode.cover
    case 'repeat':
      return FastImage.resizeMode.cover
  }
}

FastImage 是可以有子节点的哦,如图:

ui-tips-2022-07-21-12-19-54

ScrollView

ScrollViewopen in new window 是比较常用的可滚动视图。它有众多只适用于 iOS 的属性。

contentContainerStyle

ScrollView 有一个 contentContainerStyle 属性,它可以设置 ScrollView 的内容的样式。

在下面的例子中,我们希望它的内容撑满整个 ScrollView

ui-tips-2022-07-21-16-44-49

设置 flex: 1,结果内容不见了。

ui-tips-2022-07-21-17-02-18

需要设置 flexGrow: 1height: "100%" 方可。

ui-tips-2022-07-21-17-04-04

horizontal 和 flexGrow: 0

当设置 ScrollView 为横向时,我们希望它的高度是 48,然而并没有什么用,它会尽可能占据父组件更多的空间。这是因为它有一个 flexGrow: 1 的默认样式。

ui-tips-2022-07-21-17-12-15

此时,需要为设置 flexGrow: 0 来解决这个问题。

ui-tips-2022-07-21-17-15-21

scrollEventThrottle

scrollEventThrottleopen in new window 仅作用于 iOS,可以设置 ScrollView 的滚动事件的触发频率。默认值为 0,这将导致每次滚动视图时仅发送一次滚动事件。和 Aminated API 一起使用时,为了得到优雅的原生驱动的动画效果,可以设置为 16

键盘关闭策略

keyboardDismissMode 用来设置当拖动 ScrollView 时,是否关闭键盘,默认是 none。然而大多数情况下,我们设置的值都是 on-drag,即拖动 ScrollView 时关闭键盘。

keyboardShouldPersistTaps 用来设置当点击 ScrollView 时,是否保持打开键盘,默认是 never,即关闭键盘。保持默认即可。

嵌套滚动

嵌套滚动是指在一个滚动视图中嵌套另一个同方向滚动的视图。

README-2023-04-18-17-43-21

作者和他的伙伴们编写了一个支持嵌套滚动的原生 UI 组件 -- NestedScrollViewopen in new window,有需求的读者可以尝试一下。

FlatList

FlatListopen in new window 用于创建动态列表,可以显示大量数据。和其它核心组件不同,FlatList 是没有原生组件支持的。

RN UI 组件Android ViewiOS View
ViewViewGroupUIView
TextTextViewUITextView
ImageImageViewUIImageView
ScrollViewScrollViewUIScrollView
TextInputEditTextUITextField
???RecyclerViewUITableView

FlatList 在原生底层并不对应于 Andriod 的 RecyclerView 或 iOS 的 UITableView。它实际上是继承了 ScrollView 的,这意味着那些适用于 ScrollView 的规则也适用于 FlatList

ScrollView 不同,FlatList 仅渲染当前显示在屏幕上的元素,而不是一次渲染所有元素。由于分批渲染和桥的异步性,FlatList 的渲染速度不一定跟得上滚动速度,在用户快速滚动时,可能会出现一些白屏的情况。

因此 FlatList 的性能优化就显得较为重要,官方专门用一篇文档open in new window来讲述性能优化。

作者认为,性能优化这事,应该交给底层去实现,而不是 React Native 的开发者。在使用 FlatList 时,不需要惦记性能优化这些事情,一方面会增加心智负担,另一方面也增加代码复杂度,还不一定能达到优化的目的。

FlatList 这类动态列表,如果能在原生底层有对应的组件,那就最好不过了。

下拉刷新

如果设置了 onRefresh 回调函数,将会添加一个标准的 RefreshControl 来支持下拉刷新。不过 iOS 和 Android 的实现方式不一样。查看源码open in new window可知,在 iOS 上,会添加一个 UIRefreshControlScrollView 中,而在 Android 上,会将 ScrollView 包裹在 SwipeRefreshLayout 中。

可以通过设置 refreshControl 属性来更换下拉刷新的实现,使得两个平台的表现一致。

作者和他的伙伴们写了一个提供自定义下拉刷新能力的组件 -- PullToRefreshopen in new window,同时支持上拉加载更多。推荐各位读者使用。

聊天风格

如何实现聊天界面那样的风格,列表自底向上渲染?将 inverted 设置为 true 即可。其背后的原理是将 scale transform 设置为 -1

ui-tips-2022-07-22-17-14-42

网格布局

通过设置 numColumns, 可以实现网格布局。但是有限制,网格的高度必须一致,因此不能用来实现交错排列的布局。

ui-tips-2022-07-22-16-03-48

完整代码请查看一个示例open in new window

TextInput

TextInputopen in new window 用于接受文本输入,是一个较为复杂的组件。

它的 style 属性和 Text 组件的 style 属性都是TextStyleopen in new window类型。

多行文本与顶部对齐

当将 multiline 设置为 true 时,在 iOS 上,文本会与顶部对齐,而在 Andriod 上,则保持垂直居中。需要将 textAlignVertical 设置为 top,才能保持两个平台的表现一致。

在 Android 上,在垂直方向上会有默认的 padding,多行文本下,为了保持两个平台的表现一致,需要将 padding 样式属性设置为 0 或适当的值。

记得设置 lineHeight 哦,排版会好看许多。

ui-tips-2022-07-22-18-50-37

光标颜色

selectionColor 不仅可以设置选中文本的颜色,也可以设置光标的颜色。

自动纠正、自动大写、自动完成

autoCorrectautoCapitalizeautoComplete 这几个属性似乎不太适合中文语境,我们可以将其关闭。或者可以写个 polyfill 来全局设置。

响应键盘显示或隐藏

React Native 提供了 Keyboard APIopen in new window 来协助处理键盘相关问题。

比如,通过监听 keyboardDidShowkeyboardDidHide 事件,可以在键盘显示或隐藏时,调整界面布局。但效果并不理想,界面布局的调整并不会随着键盘的显示或隐藏而同步进行,而是会有延迟。

不过好在有 KeyboardInsetsViewopen in new window,它能同步地获取键盘的高度,从而可以实时而优雅地响应键盘的显示或隐藏。

KeyboardInsetsView 使用简单,自动模式下不需要额外代码就可以获得避免键盘遮挡输入框的能力。

如果要实现聊天应用那样的键盘交互,借助 KeyboardInsetsView 也可以完美达成。

README-2023-02-02-15-56-36README-2023-02-18-21-36-20
上次更新: