如何在 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 的阴影效果一致。
它只需要设置 cardElevation
和 cornerRadius
这两个属性,剩下的他就自己算了。但问题是,UI 设计稿上的数值怎么转换成这些属性的值呢?
<CardView cardElevation={2} cornerRadius={5}> <Text>Elevation 0</Text> </CardView>
也可以反其道而行之,由 elevation
倒推出 shadowOffset
shadowOpacity
shadowRadius
的值。
如果你的 App 是 Material Design 的话,那么这个工具open in new window可能会很有用。
编写原生组件来实现阴影
能不能编写一个 Android 原生组件来支持阴影呢?不止一个人这么做了。
react-native-simple-shadow-viewopen in new window 和 react-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 的阴影在哪?
经过作者一番思索,发现这个控件,它只有一个子节点的时候,才能正常工作。修改上面的代码如下:
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 的阴影相对有点粗。
使用 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
在下面的例子中,我尽量将 Shadow
和 DropShadow
所包裹的 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 |
我们看一下效果如何:
可以看到,使用 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,供你参考。