如何在 React Native 中实现无侵入式的阴影效果

在 React Native,iOS 平台和 Android 平台实现阴影的默认方式是不一致的,阴影的效果也不同。

本文试图找出一个最佳的实现方式。在此之前,先来看看有哪些实现方式。

遵从平台各自的实现方式

这种方式就是 iOS 通过 shadowOffset shadowOpacity shadowRadius 来实现阴影,而 Android 则通过 elevation 来实现阴影。

<View
  style={{
    shadowColor: "#222222",
    shadowOffset: { width: 2, height: 2 },
    shadowOpacity: 0.2,
    shadowRadius: 3,
    elevation: 5,
  }}>
  ...
</View>
复制代码

UI 设计稿,譬如 Sketch,是可以设置 shadowX 这些属性的,这使得 iOS App 能还原设计稿,而 Android App 不能。

为了能让两个平台的阴影效果一致,react-native-cardview通过一种我看不懂的算法,试图调和这些属性,使得 iOS App 和 Android App 的阴影效果一致。

它只需要设置 cardElevationcornerRadius 这两个属性,剩下的他就自己算了。但问题是,UI 设计稿上的数值怎么转换成这些属性的值呢?

<CardView cardElevation={2} cornerRadius={5}>
  <Text>Elevation 0</Text>
</CardView>
复制代码

shadow-box-2022-07-13-22-17-28

也可以反其道而行之,由 elevation 倒推出 shadowOffset shadowOpacity shadowRadius 的值。

如果你的 App 是 Material Design 的话,那么这个工具可能会很有用。

shadow-box-2022-07-13-22-21-25

编写原生组件来实现阴影

能不能编写一个 Android 原生组件来支持阴影呢?不止一个人这么做了。

react-native-simple-shadow-viewreact-native-drop-shadow 就是这样的组件。它们的实现原理都是根据传递进来的 shadowOffset shadowOpacity shadowRadius shadowColor 等属性,来生成一张 Bitmap 作为阴影。

react-native-simple-shadow-view 已经不维护了,我们来看看 react-native-drop-shadow 的效果。

这篇博文 Applying box shadows in React Native 介绍了如何在 React Native 中使用阴影。作者参考了里面的例子。

import DropShadow from "react-native-drop-shadow"

function ShadowBox() {
  return (
    <View style={styles.container}>
      <DropShadow style={[styles.card, styles.boxShadow]}>
        <View>
          <Text style={styles.heading}>React Native cross-platform box shadow</Text>
        </View>
        <Text>Using the Platform API to conditionally render box shadow</Text>
      </DropShadow>
    </View>
  )
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  heading: {
    fontSize: 18,
    fontWeight: "600",
    marginBottom: 13,
    lineHeight: 30,
  },
  card: {
    backgroundColor: "white",
    paddingVertical: 45,
    paddingHorizontal: 25,
    width: "100%",
    borderRadius: 8,
  },
  boxShadow: {
    shadowColor: "#222222",
    shadowOpacity: 0.24,
    shadowOffset: { width: 0, height: 0 },
    shadowRadius: 4,
    margin: 36,
  },
})
复制代码

啊哈,请问 Android 的阴影在哪?

shadow-box-2022-07-13-23-20-08

经过作者一番思索,发现这个控件,它只有一个子节点的时候,才能正常工作。修改上面的代码如下:

import DropShadow from "react-native-drop-shadow"

function ShadowBox() {
  return (
    <View style={styles.container}>
      <DropShadow style={styles.boxShadow}>
        <View style={styles.card}>
          <View>
            <Text style={styles.heading}>
              React Native cross-platform box shadow
            </Text>
          </View>
          <Text>Using the Platform API to conditionally render box shadow</Text>
        </View>
      </DropShadow>
    </View>
  )
}
复制代码

看起来还不错的样子,感觉 Android 的阴影相对有点粗。

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

使用 svg 来实现阴影

社区还有一种方法,就是使用 svg 来实现阴影。

react-native-neomorph-shadows 底层使用了 react-native-art 来实现阴影,但是 art 已经不再维护了,它推荐我们使用 react-native-svg 来代替。

react-native-shadow 正是基于 svg 来实现的阴影,但是它已经死了很多年。

就在几个月前,react-native-shadow-2 诞生了。它声称自己继承了 react-native-shadow,并且没有 react-native-shadow 那些毛病。

react-native-shadow-2 使用如下 UI 结构来包裹我们的组件,其中 shadow 就是使用 svg 生成的阴影,children 则是我们的组件。

<View style={containerViewStyle} pointerEvents="box-none">
  <View style={[{ ...StyleSheet.absoluteFillObject }]}>{shadow}</View>
  <View style={[viewStyle]}>{children}</View>
</View>
复制代码

现在让我们来看看,它实现的阴影效果是否和 iOS 的默认效果一致。

yarn add react-native-shadow-2 react-native-svg
复制代码

在下面的例子中,我尽量将 ShadowDropShadow 所包裹的 UI 层级以及属性设置成一样。

import DropShadow from "react-native-drop-shadow"
import { Shadow } from "react-native-shadow-2"

function ShadowBox() {
  return (
    <View style={styles.container}>
      <DropShadow style={styles.boxShadow}>
        <View style={styles.card}>
          <View>
            <Text style={styles.heading}>
              React Native cross-platform box shadow
            </Text>
          </View>
          <Text>Using the Platform API to conditionally render box shadow</Text>
        </View>
      </DropShadow>

      <Shadow
        startColor={"#2222223d"}
        offset={[0, 0]}
        distance={4}
        radius={8}
        containerViewStyle={{ marginHorizontal: 36 }}
        viewStyle={styles.card}>
        <View>
          <Text style={styles.heading}>React Native cross-platform box shadow</Text>
        </View>
        <Text>Using the Platform API to conditionally render box shadow</Text>
      </Shadow>
    </View>
  )
}
复制代码

Shadow 的属性和 View 或 DropShadow 相关属性对应关系如下:

Shadow View/ DropShadow
startColor shadowColor & shadowOpacity
offset shadowOffset
distance shadowRadius
radius borderRadius

我们看一下效果如何:

shadow-box-2022-07-14-02-15-37

可以看到,使用 react-native-shadow-2,iOS 的阴影效果和 Android 的阴影效果是一致的。

但是有个比较严重的问题,就是 startColor 的值的计算。要怎样才能和设计稿的效果一致呢?左上角那个 iOS 默认效果就是和设计稿一致的。

react-native-shadow-2 的作者回答说,你可以做的只是调整属性(distance, startColor, offset),直到达到与你在 Figma 设计中的外观相似。

最佳方案

作者推荐使用原生组件的方式来为 Android 平台实现阴影效果。因为只需要照着 UI 稿的数值来就可以了,并且效果和 iOS 的默认效果差别不大。

但能不能像 iOS 那样,仅用一个 View 就实现阴影效果呢?

就像下面这样使用,Android 假装它的 View 也支持 shadowRadius shadowOffset shadowOpacity 这些样式属性。

<View style={[styles.boxShadow, styles.card]}>
  <View>
    <Text style={styles.heading}>React Native cross-platform box shadow</Text>
  </View>
  <Text>Using the Platform API to conditionally render box shadow</Text>
</View>
复制代码

答案是可以的,只需添加一个 polyfill。

// shadow-polyfill.ts
import React from "react"
import { Platform, View, StyleSheet, ViewProps, ViewStyle } from "react-native"
import DropShadow from "react-native-drop-shadow"

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

// @ts-ignore
View.render = function (props: ViewProps, ref: React.RefObject<View>) {
  if (Platform.OS === "ios") {
    return __render.call(this, props, ref)
  }

  const { style, ..._props } = props

  const _style = StyleSheet.flatten(style) || {}
  const keys = Object.keys(_style)

  if (!keys.includes("shadowRadius")) {
    return __render.call(this, props, ref)
  }

  delete _style.elevation
  const { outer, inner } = splitShadowProps(_style)

  console.log("outer style: ", outer)
  console.log("inner style: ", inner)

  return React.createElement(
    DropShadow,
    { style: outer },
    __render.call(this, { ..._props, style: inner }, ref)
  )
}

type StyleKey = keyof ViewStyle

function splitShadowProps(style: ViewStyle) {
  let outer: { [key: string]: any } = {}
  let inner: { [key: string]: any } = {}

  if (style != null) {
    for (const prop of Object.keys(style) as StyleKey[]) {
      switch (prop) {
        case "margin":
        case "marginHorizontal":
        case "marginVertical":
        case "marginBottom":
        case "marginTop":
        case "marginLeft":
        case "marginRight":
        case "flex":
        case "flexGrow":
        case "flexShrink":
        case "flexBasis":
        case "alignSelf":
        case "height":
        case "minHeight":
        case "maxHeight":
        case "width":
        case "minWidth":
        case "maxWidth":
        case "position":
        case "left":
        case "right":
        case "bottom":
        case "top":
        case "shadowColor":
        case "shadowOffset":
        case "shadowOpacity":
        case "shadowRadius":
          outer[prop] = style[prop]
          break
        default:
          inner[prop] = style[prop]
          break
      }
    }
  }

  if (outer.flex) {
    inner.flex = 1
  }

  return { outer, inner }
}
复制代码

这个 polyfill 使用了一些 React 顶层 API,掌握它们,可以做一些有趣的事情。

示例

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

猜你喜欢

转载自juejin.im/post/7128194484591919141