坑爹的shadow -- 总结 与 各种坑

3,628 阅读10分钟

最近公司来了新UX总监, 很喜欢给设计添加浓重的, 而且是好几层的阴影. 这下就苦了我们Android开发了. 因为是Android不支持啊, 巧妇也难为无米之炊啊. (折中方法也不是没有, 就是自己把阴影做个view, 但它的blur这些比较麻烦, 做过Android的都知道这个Blur要用到BlurScript之类, 做起来不容易)

Android的shadow之痛

以下图中一个矩形有阴影为例, 它的shadow是有多种参数的, 主要就是: offsetX, offsetY, blur, spread, color & alpha. Ux在figma等软件上设计好的样子如下:

image.png

而UX与PO天天挂嘴边的: "为什么人家iOS可以, 为什么人家web可以, 就你Android不行?", 这个是因为人家有支持啊.

  • web用css: filter: drop-shadow(5px 5px 5px rgba(0,0,0,0.3));
  • iOS的view的layer也支持 (iOS的绘制也是有一层一层的, 类似Android中的FrameLayout一样, 可以一层层堆叠):
let yourView = UIView()
yourView.layer.shadowColor = UIColor.black.cgColor
yourView.layer.shadowOpacity = 1
yourView.layer.shadowOffset = .zero
yourView.layer.shadowRadius = 10

但我们Android确实对shadow的支持从来就弱. 若是对文字的阴影, 那TextView确实有部分支持, 但blur效果就不支持:

<TextView android:id="@+id/txt_example1"
      ...
      android:shadowColor="@color/text_shadow"
      android:shadowDx="1"
      android:shadowDy="1"
      android:shadowRadius="2" 

要是想像UX要求的一样, 给任意View添加阴影, 好就麻烦了. 当然, Android自己也意识到这一点了, 所以在Android 5之后, 即在新引入的Material Design里添加了阴影的支持. 但它的阴影理论自成一套, 根本跟figma上的shadowOffsetX, shadowOffsetY, shadowBlur, shadowRadius不一样. 它的一套理论更像是光照系统.

Android 5.0之后的阴影&光照系统 -- 理论部分

题外话: 现在app的minSDK不至于少于5.0吧, 所以现在只郑重讲Android5.0之后的阴影系统.

这一章节是阴影的理论部分. 话说我也不想讲理论, 太枯燥, 所以我尽量讲得简略些, 只挑重点讲. 后面再结合实践来验证这些理论, 来加深理解.

Material Design其实更像是一个光照系统. 它假定在远方有一个光源, 然后照向你的view. 这样你的view若是离手机屏幕有一些高度的话, 那就会在手机屏幕上形成阴影. image.png

注意这个阴影比较逼真, 在边缘因为有光照与阴影的同时干涉, 所以阴影较浅 (即下图中的红色部分) 而中间的阴影更浓 (即下图的蓝色部分)

image.png

  • 较浅阴影在Material Design中的术语叫做: ambient shadow (环境阴影)
  • 较深阴影的术语叫: spot shaodw (聚光灯阴影)

同样, 上面也说了, 若是你的View紧贴手机屏幕, 那也不会有阴影的. 你的View只有抬起来一点高度, 才会形成阴影. 这跟日常生活中的体验是一样的. 而这个"抬起来的高度", 在Android中的术语就是: elevation, 你可以理解为z轴上的高度啦. 当elevation不同, 自然阴影也不一样. 如下面的表格, 分别代表了elevation为2dp与10dp时的结果:

image.png

好了, 上面就是重要的三个阴影关键: ambient shadow, spot shadow, 以及elevation.

阴影的实践

当你的view有了elevation时, 你就天然会形成两种阴影: ambient与spot shadow.

image.png

同样Android也提供了一共5个API来帮我们设置阴影. 我按照since API Level xx做了分类:

  • Since Api 21:
    • android:elevation : 在view中设定
    • android:spotShadowAlpha: 在theme这个xml中设定
    • android:ambientShadowAlpha: 在theme这个xml中设定
  • Since Api 28:
    • android:spotShadowColor: 在view中设定
    • android:ambientShadowColor: 在view中设定

听起来好像蛮简单的, 有了这5个api, 微调下值就能得到和UX设计的近似的阴影, 这就算完工了. 但现实生活中开发总是悲催得多, 比如说你设了这5个api, 但现实中却发现一点点子阴影都没有. 这是怎么了?
: 这就不得不说官网上根本没有详细讲述的一些坑了. 不解决这些坑, 我们的阴影仍是不行的.

设置阴影的多个坑

坑1: 设置了elevation仍没有阴影

下面的代码就是我以前写过的一个代码. 按理说我的elevation已经有了, 而ambient + spot shadow的color, alpha都有默认值 (手机上就淡淡的黑色灰影; TV上则是更重些的灰色), 那就应该有阴影. 但不幸的是, 最终效果是完全没有阴影效果.

<SomeView
    android:elevation="24dp"
    />

原因是: Android中你的View得有一个背景, 颜色或图片都可以, 那你才会有阴影. 当我上面的view没有背景, 那Android根本就不会为它生成背景, 因为它把这个view当成透明的了, 一个透明的东西在光照下自然是没有阴影的.

解决办法:

<SomeView
    android:background="@drawable/some_bg"
    android:elevation="24dp"
    />

坑2: 下载的图片素材做bg, 但仍没有阴影

我有一个view, 其是有背景的. 我去figma上下载这个背景,

image.png

并导入到Android Studio后, 命名为bg_pink_polygon, 但下面的代码仍是没有阴影.

<SomeView
    android:background="@drawable/bg_pink_polygon"
    android:elevation="24dp"
    />

: 原因其实是Android要求你的view有bg, 才可能会有阴影 但前提是这个bg, 不能是SVG形成的<vector> xml, 不然Android也不会为它生成阴影.

也就是说, 你的bg可以是这样的:

  • 纯color值, 如 #ff00cc
  • 纯png, jpg图片, 如bg_abc.png
  • 或为点9图片, 如bg_abc.9.png
  • 或是<shape>的drawable xml,
  • 或是item为<shape><layer-list>的drawable xml

但是, 唯独有一点, 你的bg不能是<vector>的drawable, 不然就没有阴影.

坑3: 阴影需要额外空间吗?

比如说我们的View的宽高是100x100, 而UX要求阴影的offsetX, offestY为20dp, 我换算成elevation为多少dp后, 假设阴影占20x20的空间, 那最终UI效果要这样吗?

<FrameLayout width=120dp height=120dp>
    <View width=100dp height=100dp/>
</FrameLayout>

: 一般来说不需要. 这一点Android做得还是可以的, 你只要考虑你的View的尺寸就行了, 阴影的空间Android会自动画出来, 不用你担心.

但是现实生活中更复杂些, 比如要求一个view有阴影, 而这个view在另外的其它自定义View中, 那这时就不好讲. 可能这时的阴影就被cut off了. 我就碰到过这样的实例.

至于原因则可能是为android中默认是child view不能超过parent view group的边界, 超出了的部分会不被绘制出来. 这也包括了阴影. 所以阴影也被截断了.

这里你可能就要做额外一层layout. (这一点蛮恶心了, 牺牲了性能就为了个阴影, 所以我也不喜欢过多的阴影设计)

<!-- 原来是 --> 
<ConstraintLayout elevation=24dp backgorund=xx> 
    .... <!-- children -->

现在为了给这个constraint layout添加阴影, 并不被截断, 就得外加一层layout

<FrameLayout padding=8dp clipToPadding=false>
    <ConstraintLayout elevation=24dp backgorund=xx>

这个FrameLayout存在的唯一目的就是留出空间, 来给constraitLayout提供绘制阴影的空间.

更多阴影设置

前言: 为什么view一定要有bg, 才会有阴影的可能?

其实我们上面的话不太对, 即这句: "光源对view照下来, 形成了阴影"

而上一节中, 我们修正了这句话, 即应该是: "光源对view的背景照下来, 形成了阴影".

其实这次的修正仍是不对的, 正确的说法是"光源对View的outline provider照下来, 形成了阴影".

Android中View是自带了outline provider的, 默认值就是background. 其它的可选值如下:

image.png

这下其实也就解释了, 为什么要有bg, 才会有shadow : 因为默认就是background的outline provider啊. 要是view没有background, 就没了outline provider, 那就自然就没了阴影.

outline provider

那是不是说当我把outline provider设置了非background的其它值, 那即使这个view没有bg, 只要有elevation就会有阴影? : 是的, 答对了.

自定义shadow的形状

outline provider的另一作用, 就是可以让你自定义shadow的shape, 比如说不再是矩形, 可以变成圆形, 五角星形, ...

你所需要做的, 就是自定义一个outline provider

class MyShadowOutlineProvider(
    val cornerRadius: Float = 0f,
    var offsetX: Int = 0
    var offsetY: Int = 0
) : ViewOutlineProvider() {

    private val rect: Rect = Rect()

    override fun getOutline(view: View?, outline: Outline?) {
        view?.background?.copyBounds(rect)
        rect.offset(offsetX, offsetY)
        outline?.setRoundRect(rect, cornerRadius)
    }
    
}

然后view中指明使用这个outline provider:

        outlineProvider = MyShadowOutlineProvider(14f.dpToPx(), 1f, 1f, 0)
        btnShadowDemo.outlineProvider = outlineProvider
        btnShadowDemo.elevation = 30f //elevation仍是需要的!

最佳实践推荐

theme中设置alpha为1

因为theme中把ambientShadowAlpha, spotShadowAlpha给定死了. "定死了"就是无法在代码中修改这两个的alpha值, 就不太灵活.

一个取巧的办法则是:

    <style name="Theme.MyApp" parent="Theme.MaterialComponents.DayNight.NoActionBar">
        <item name="android:ambientShadowAlpha">1</item>
        <item name="android:spotShadowAlpha">1</item>      
		... ...
    </style>

1). theme中把这两个alpha全设为1 2). 然后阴影color就可以加上alpha了, 如

// 原来是:
setSpotShadowColor(0x000000) //颜色是 rgb

// 现在则要给color加上alpha
setSpotShadowColor(0x88000000) //变成了 argb

这样一来我们就能灵活更改shadow的颜色与透明度了.

若无定制shadow形状的要求

那就是:

1). 给view添加非svg的bg

2). 再加上elevation

3). (可选) 可修改ambient/spot shadow的color

阴影就出来了

若有定制shadow形式的要求

那就要:

1). 自定义一个outline provider

2). view.outlineProvider = myOutlineProvider

3). 再加上elevation

阴影也同样出来了, 还是你自己定制的shape.

另一种阴影的思路

这一种思路, 其实就是完全抛弃了ambient, spot shadow + elevation + outline provider的体系, 走自己的阴影, 而且还蛮符合figma上的UX设计的.

这思路的主要思想就是利用:

paint.setShadowLayer(radius, offsetX, offsetY, shadowColor)

你可以把这个逻辑放到你的ViewGroup里, 或是放到一个Drawable里, 或是放到自己的自定义View里. 效果算还行.我个人感觉麻烦了点, 要额外多做些工作. 不过网上有不少三方库了, 要么抽象到Layout里, 要么抽象到Drawable里, 方便大家使用.

备注: Android8及以下的硬件加速都不支持对view画阴影的Paint # setShadowLayer的, 偏偏手机是默认打开硬件加速的, 所以这个方法也只能对Android P (Android 9.0)及以上的硬件加速才有效

image.png

p.s. 当然也可以用myView.setLayerType(View.LAYER_TYPE_SOFTWARE, null)来给View强制禁止硬件加速.

一个实例教你如何设置阴影

现在来一个案例哦. 如给下图的按钮添加"0000ff"(蓝色)的阴影 :

image.png

1. res/values, res/values-v28

// res/values/themes.xml
<style name="MyTheme" parent="BaseTheme">
    <item name="android:ambientShadowAlpha">0.05</item>
    <item name="android:spotShadowAlpha">0</item>
</style>

因为上方所讲, 阴影是在Android 9上才有, 所以得有一个res/values-v28目录:

<style name="MyTheme" parent="BaseTheme">
        <item name="android:ambientShadowAlpha">1</item>
        <item name="android:outlineAmbientShadowColor">#cccccc</item>
        <item name="android:spotShadowAlpha">0</item>
    </style>

注意, 这两个alpha属性是在theme中才有. 也就是说, View可以设置outlineAmbientShadowColor, 但它不能设置outlineAmbientShadowAlpha

2. 这个按钮得有个background, 且这个background不能是vector

背景就是上面的灰色圆角矩形

// res/drawable/bg_grey_round_corner.xml

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
    <solid android:color="#ccc"/>
    <corners android:radius="40dp"/>
    <stroke android:color="#000" android:width="1dp"/>
</shape>

3. 给button添加阴影色与z高

在Android中一定要有z轴上的高度(也就是elevation), 才会产生阴影. 上面理论部分讲过了. 所以这里就要设置:

    <Button 
        android:layout_height="100dp" android:layout_width="250dp"
        android:background="@drawable/bg_grey_round_corner"
        android:outlineAmbientShadowColor="#00f"
        android:elevation="74dp"
        />

重点就是最末两行, 一个设置阴影为蓝色, 另一个设置z轴上的高度(也就是阴影相关的参数).

4. (可选) 加一个parent

在一些特别场景(如自定义View时), 若是UX Review时仍说阴影不清楚, 那可能是阴影被限制在view自己的范围里了. 这时我们就加一个parent, 加一个padding与clipToPadding, 那就有了阴影效果了.

<FrameLayout  android:padding="20dp" android:clipTpPadding="false"> 
       <Button 
        android:layout_height="100dp" android:layout_width="250dp"
        android:background="@drawable/bg_grey_round_corner"
        android:outlineAmbientShadowColor="#00f"
        android:elevation="74dp"
        />

最终效果就是:

image.png

参考资料

  1. Material Design - light & shadow
  2. Material Design - Elevation
  3. Material Shadow on Android
  4. Material Design - Elevation & shadow
  5. 个人在公司项目中的实践经验
  6. Hardware Accelerator