无侵入式的阴影效果 4个月前

如何在 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-cardviewopen in new window通过一种我看不懂的算法,试图调和这些属性,使得 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 的话,那么这个工具open in new window可能会很有用。

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

编写原生组件来实现阴影

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

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

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

这篇博文 Applying box shadows in React Nativeopen in new window 介绍了如何在 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-shadowsopen in new window 底层使用了 react-native-artopen in new window 来实现阴影,但是 art 已经不再维护了,它推荐我们使用 react-native-svgopen in new window 来代替。

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

就在几个月前,react-native-shadow-2open in new window 诞生了。它声称自己继承了 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 的作者回答说open in new window,你可以做的只是调整属性(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 顶层 APIopen in new window,掌握它们,可以做一些有趣的事情。

示例

这里有一个示例open in new window,供你参考。

image
EchoEcho官方
无论前方如何,请不要后悔与我相遇。
1377
发布数
439
关注者
2243853
累计阅读

热门教程文档

Dart
35小节
MyBatis
19小节
Kotlin
68小节
Docker
62小节
CSS
33小节