聊聊 Material Design 里,阴影的那些事儿!

7,318 阅读16分钟

当你的设计师要求你在某个 View 上增加阴影效果,那你只需要认真阅读本文,阴影的问题就不再是问题。

一、前言

设计师的世界,与常人不同,有时候想要扁平化的风格,有时候又想要拟物化的风格。而在 Material Design 出来之后,为 UI 元素引入了高度的概念,它可以让某个元素更为突出,显示出它的重要性,更让人有点击的欲望。

在拟物化的设计里,UI 元素的高度,反应在效果上,就是在边框上有阴影的效果,感觉它是距离底部有一个层次的关系。在 Material Design 的设计中,也大量的使用了 阴影 的效果,例如:FloatingActionButton、CardView 这些控件,都是默认支持阴影效果的。

如果你想了解 Material Design 中,更多关于阴影的设计,可以查阅官方文档。

material.io/guidelines/…

接下来,我们就来介绍一下,在 Android 的不同版本中,使用不同的方式,去实现阴影的效果。

先来看看实现的效果,虽然多,但是它们实现的方法都不相同。

/all_5.jpeg
/all_5.jpeg

二、阴影的效果

在拟物化的世界里,阴影主要是对三维空间中的 Z 属性进行操作。下面是官网的介绍。

由 Z 属性所表示的视图高度将决定其阴影的视觉外观:拥有较高 Z 值的视图将投射更大且更柔和的阴影。 拥有较高 Z 值的视图将挡住拥有较低 Z 值的视图;不过视图的 Z 值并不影响视图的大小。

阴影是由提升的视图的父项所绘制,因此将受到标准视图裁剪的影响,而在默认情况下裁剪将由父项执行。

developer.android.com/training/ma…

静态效果如下:

/shadows-depth.png
/shadows-depth.png

再加上,动态的效果应该更能让你对阴影有所理解。

三、使用标准 Api

Material Design 首次出现在 Android 5.0 中,之后又有一些 Support 包,让更低的版本,对 Material Design 进行支持。

而在 Api Level 21 之中,增加了两个属性 :

  • elevation:高度,用于提升 UI 元素高度的属性。
  • translationZ:Z 轴的变换效果。

这两个属性,有对应的 xml 属性和 setXxx() 方法,而 Z 轴的改变,主要是由这两个属性决定的。

Z = elevation + translationZ

所以,如果你的 App 的 minSdkVersion 就是 21 的话,直接使用这两个属性是最优的解决办法。

3.1 elevation 属性

elevation 属性,主要用于给 View 增加一个高度,可以直接被加在 View 控件上,呈现在界面上,就是一个带阴影的效果。

在 layout-xml 布局中,可以通过 android:elevation 属性来设置,而在 Java 代码中,通过 View.setElevation() 方法来使用它。

直接使用 elevation 属性设置即可,它接收一个高度的参数,只需要按我们的需要配置即可。

需要注意的是,View 的阴影一定是需要有背景的 View 在视觉上增高之后,投射出来的。也就是类似于打光的阴影效果。简单来说,就是需要为 View 设置一个 Background,可以使用 android:background 属性或者 View.setBackground() 方法设置,否者 elevation 的属性设置将无效。这里的 Background 只需要设置一个 Drawable 即可,你当然也可以选择一个图片或者一个纯色的 了。

下面来看看 elevation 属性的效果:

/setElevation.png
/setElevation.png

往深里再看看 elevation 属性的实现方式。

/setElevationMethod.png
/setElevationMethod.png

它最终还是调用的 mRenderNode 去做的操作,在追踪下去,就会发现它底层是用的 native 的方法实现的,所以应该不是我们所理解的用 2D 的渐变模拟阴影的效果。

3.2 translationZ 属性

translationZ 属性,主要用于给 View 增加一个在 Z 轴上的变换效果。它和 elevation 配合起来,就是一个一加一等于二的效果。也可以用于设置 View 的高度。

在 layout-xml 布局中,可以通过 android:translationZ 属性来设置它,而在 Java 代码中,可以通过 View.setTranslationZ() 方法来使用它。

一般来说,我们可以直接使用 android:translationZ 属性来设置 View,当你配合 android:elevation 属性一起使用的时候,它们对 View 的高度是累加的,当然你也可以只使用其中一个属性。

而看到 translationZ 这样的属性,很轻易就联想到了 translationX 和 translationY 了,它们实际上就是不同维度的设置,思路上很像,但是原理不同。对 X、Y 轴的操作并没有 Api Level 的限制,这一点需要清楚。

和 elevation 属性一样,translationZ 也是需要配合 Background 的设置才会生效的,这个应该不难理解。

下面我们来看看 translationZ 属性的设置效果:

/setTranslationZ.png
/setTranslationZ.png

使用 translationZ 属性实现的效果,看着和 elevation 的效果很像,而它内部也是依赖于 mRenderNode 去做的实现。

3.3 ViewCompat 来兼容 Api

前面就已经提到,当你的 minSdkVersion 达不到 elevation 和 translationZ 这两个 Api 的要求,设置为 Api Level 21(Android 5.0) 以下。你在使用这两个属性的时候,会给你提示 Warning,如果打包的时候有 Lint 的校验,也是会提示并且导致打包失败的。

不过看提示你也能发现到底是什么问题:

Attribute elevation is only used in API level 21 and higher

如果已经明确在低于 Api Level 21 之下的版本,都不加阴影的效果,你可以在布局中,使用 tools:targetApi="lollipop" 来消除这个 Warning。

如果你是在 Java 代码中,为 View 动态设置 elevation 或者 translationZ 属性的话,除了使用 Build.VERSION_CODES.LOLLIPOP 判断之外,还可以使用 ViewCompat 这个 Android 为我们提供的标准的 View 兼容类,当然,这里推荐使用 ViewCompat。

既然要用到 ViewCompat 的话,那我们来看看它的原理是什么。

/ViewCompatImpl.png
/ViewCompatImpl.png

在 ViewCompat 中,会有很多个实现了 ViewCompatBaseImpl 的接口类,它们分别对应了不同的 Api Level ,会在静态代码块中,根据当前运行设备的 Api Level ,做不同的实现。而这些,都是高版本继承低版本的实现,来达到继承兼容的效果。

ViewCompatBaseImpl 这个接口中,定义了很多关于 View 的操作 Api ,这些 Api 都是存在不同的 Api 版本限制的。

在 Api Level 21 中,本身就已经支持了这两个属性,也就不存在兼容性的问题了,所以它其中会直接调用 setElevation()setTranslationZ() 方法。

/ViewCompatV21.png
/ViewCompatV21.png

那么,我们只需要关心 Api Level 21 以下的实现。通常来说,我们做兼容处理,一个方案就是在低版本上,使用一些只在低版本上存在 Api,来对高版本的效果进行模拟;另外一个方案就是放弃低版本,完全对它不做任何处理。

我们来看看 ViewCompat 是对 Elevation 是选用的那个方案。其实 Api Level 21 之下,都没有对这两个属性的操作方法,做任何的处理,你一路追踪下去可以追踪到 ViewCompatBaseImpl 。

/ViewCompatBaseImpl.png
/ViewCompatBaseImpl.png

从这里可以看出,ViewCompat 没有对这两个方法做任何的兼容,在低版本上,没有做任何的操作,这也导致了你如果使用 ViewCompat 的话,在低版本上是不会有阴影的效果的。没有就是没有,这里就不再单独展示了。

那看看使用 ViewCompat 在高版本上的效果图,其实和之前的也没啥区别,不过摆在一起看更清晰一些。

/Api-demo-pic.png
/Api-demo-pic.png

3.4 标准 Api 小结

到现在你也能看到,如果不在意 Api level 的话,你完全可以使用 android:elevationandroid:translationZ 两个属性来做的阴影的效果,效果也是非常好的,而且它的阴影实际上是不占用 View 的布局大小的,它会在原本的布局之外,向外扩散,所以也不会影响 View 本身大小的视觉效果。

不过它也有缺陷,你只能通过设定这两个属性来调整阴影的大小,没办法做到精确掌控,并且无法修改阴影的颜色。

最新的 Android 版本市场占有率,你可以在这个网站上查到。

developer.android.com/about/dashb…

截止到本文编写的时候,低于 5.0 的版本,差不多在 20% 左右,是否对这部分用户,放弃阴影的效果,取决于你的产品和设计师。

/Android-level.png
/Android-level.png

如果你需要兼容低版本的设备,后面介绍的一些方法,都可以做到,继续往下阅读吧。

四、使用9Patch图

4.1 什么上 9Patch 图

如果你需要兼容低版本的 Android 设备,使用 android:elevation 和 android:translationZ 是无法做到的,它们会在低版本上失效,完全没有效果,当然前提是你需要做好 Warning 的处理。

而这种阴影的效果,使用 .9图,也是一个不错的选择。

.9 图 就是 9Patch, 引用官网的介绍:

Draw 9-patch 工具是 Android Studio 中包含的一种 WYSIWYG(所见即所得)编辑器,利用此工具,您可以创建能够自动调整大小以适应视图内容和屏幕尺寸的位图图像。图像的选定部分可以根据图像内绘制的指示器在水平或竖直方向上调整比例。

developer.android.com/studio/writ…

4.2 使用 9Patch 设置阴影

直接制作一个带阴影效果的 .9 图片,然后设置好内容区域和拉伸区域,就可以在其中模拟出阴影的效果。

举个例子,使用一个 .9 图,然后设置在 ImageView 上的背景。

/9patch.png
/9patch.png

在 layout-xml 上,只需要给 ImageView 设置好 android:background 就可以了。

/9patch-xml.png
/9patch-xml.png

来看看它实现的效果:

/9patch-xiaoguo.png
/9patch-xiaoguo.png

使用 .9 图设置的阴影,效果一般都是有保障的。不过它会作为 View 的背景被设置,所以阴影上占据 View 的大小的,所以使用图片模拟出来的阴影,View 本身的视觉效果会小。

放张单图,可能看不出效果,将一个使用 ViewCompat 实现的效果,放在一起,你就可以看到对比的效果。

/9patch-duibi.png
/9patch-duibi.png

这里,两个 ImageView ,实际设置的大小,都是 100dp,但是视觉上,使用 .9 实现的效果,视觉效果就会小。

4.3 快速制作 9Patch

.9 的图,一般都是设计师会提供给我们。这里也推荐一个可以制作阴影效果的在线工具。

inloop.github.io/shadow4andr…

通过这个工具,你可以对 .9 图做各种调整,例如:圆角、阴影的大小、阴影的颜色等等,都是非常方便的设置。前面例子中使用的 .9 文件,就是使用此工具制作的。

/9patch-web.png
/9patch-web.png

还有一种方式,就是使用 这个层级的 Drawable 去模拟阴影,等于一层一层的叠加。不过使用这种方式太麻烦了,而且效果也很难做到非常的好,一般也不推荐。

4.4 9Patch 模拟阴影小结

使用 .9 图,制作阴影,基本上不需要担心效果的问题,使用起来也非常的方便。唯一的问题就是它的阴影部分,会占用 View 本身的大小,导致 View 在视觉上缩小。

总结来说,它的优点:

  1. 实现方便,只需要设置背景即可。
  2. 阴影的效果可控,颜色、圆角、阴影大小都是可以调整的。

它的缺点也非常的明显:

  1. 为了让 View 在视觉上和效果图匹配,需要预留出阴影的空间。

五、使用 FAB 的原理模拟阴影

我们知道,在 Android 对 Material Design 的效果中,有一些控件,就是自带阴影效果的,并且它也是对低版本兼容的。例如:FloatingActionButton 、CardView 等。

那么,本小结就来看看 FloatingActionButton 实现阴影的原理。

5.1 FAB 的阴影原理

就 FAB 这种有 Support.design 包支持的控件,一般都有对 不同的 Api Level 做支持处理,在 FAB 之中也是一样的,它会根据不同的 Api Level 实现不同的逻辑。

/fab-createImpl.png
/fab-createImpl.png

可以看到,这里会根据 21、14、<14 三个条件,分别使用不同的实现类,它们内部实际上实现的都是相同的功能。

如果仔细观察这些 FAB 不同版本的实现类的源码,你可以发现它的阴影效果,都是基于一个 ShadowDrawableWrapper 这个 Drawable 来实现的。

例如在 FloatingActionButtonGingerbread 中,就有这样一段设置背景的代码。

/fab-gingerbread-background.png
/fab-gingerbread-background.png

这里完全上依赖 ShadowDrawableWrapper 来做的阴影效果。

不过 ShadowDrawableWrapper 被声明的可见性为包内可见,所以我们没有办法直接使用它。

/fab-shadowdrawable-class.png
/fab-shadowdrawable-class.png

不过,鉴于 support.design 包中的类,一般都是为了兼容做处理,这里我们只需要将它和它实现的接口 DrawableWrapper 这两个类,拷贝出来,就可以直接使用了。它们的源码都在 android.support.design/widget 包下面,非常容易找到。

它的原理是在你本文需要设置的 Drawable 之外,再包装一个 Drawable ,然后在这个包装的 Drawable 上绘制阴影。

绘制的代码挺多的,这里就不贴代码了,有兴趣可以看看它的源码,主要关注 drawShadow() 方法即可。

而如果你在拷贝源码的时候,应该能发现,它实际上是可以支持改变阴影的颜色的,如果你有这种需求,只需要再扩展它的构造方法,或者直接在 colors.xml 中配置对应的颜色,它设置颜色地方如下。

/shadowWrapper-method2.png
/shadowWrapper-method2.png

可以看到,它主要用三个颜色来做一个渐变的阴影效果。

5.2 使用 FAB 的原理模拟阴影效果

前面说的,我们只需要将 ShadowDrawableWrapper 和 DrawableWrapper 这两个文件复制到我们的工程内,稍微修改一下它们的依赖关系。

/fab-project-path.png
/fab-project-path.png

如果直接拷贝源码,你会发现它还依赖三个颜色,分别是用于设置阴影的颜色的,这个前面也提到过。一般而言,我们不需要设置它,直接从源码中将它们拷贝出来就可以了。

/fab-shadow-colors.png
/fab-shadow-colors.png

然后我们就可以在 Java 代码中,为 View 动态设置一个阴影效果。

/fab-shadow-javacode.png
/fab-shadow-javacode.png

这些参数,你可以自行根据效果配置,它们的含义,其实看看方法的签名,你就清楚了,这里就不再赘述了。

/shadowWrapper-method.png
/shadowWrapper-method.png

那么,我们来看看使用 FAB 的 ShadowDrawableWrapper 模拟出来的阴影效果如何。

/fab-xiaoguo.png
/fab-xiaoguo.png

5.3 FAB 模拟阴影效果小结

前面提到,ShadowDrawableWrapper 的原理是对原本的 Drawable 做一个包装,在外围绘制阴影的效果,所以说它实际上,阴影部分也是需要占据 View 的空间的,依然会有视觉上,View 会变小。

不过它的阴影颜色上可控的,也就是说我们可以动态的为其设置阴影的颜色,这样应该会更灵活一些。

六、模拟 CardView 实现的阴影

我们知道,在 Android 对 Material Design 的效果中,有一些控件,就是自带阴影效果的,并且它也是对低版本兼容的。例如:FloatingActionButton 、CardView 等。

那么,本小结就来看看 CardView 实现阴影的原理。

6.1 CardView 的阴影原理

CardView 在 support.design 包中,你是找不到的,它被放在了 cardview-v7 包中,现在已经可以单独引用了。

CardView-v7 包中,代码非常的少。

/cardview-lib-path.png
/cardview-lib-path.png

一共就这么几个,一样就可以看到来,有一些类是做 Api 版本兼容的,并且也上如此。

/cardview-impl.png
/cardview-impl.png

在其中,还有一个 RoundRectDrawableWithShadow 类,它就是我们要找到,CardView 实现的 Drawable,它只在 CardViewJellybeanMr1 和 cardViewGingerbread 这两个类中使用,CardViewApi21 中,依然是使用的 setElevation() 方法来处理的阴影。

用之前 FAB 的经验,将 RoundRectDrawableWithShadow 直接拷贝出来,然后运行你会发现有报错。主要是因为其中有个静态的变量 sRoundRectHelper 为空了,没有被初始化。

仔细查源码你会发现,它在 CardViewJellybeanMr1 和 CardViewGingerbread 的实现原理并不相同。它们会在 initStatic() 方法中,对 sRoundRectHelper 变量进行初始化。

CardViewJellybeanMr1.initStatic() 方法如下:

/cardview-jellybean-initstatic.png
/cardview-jellybean-initstatic.png

CardViewGingerbread.initStatic() 方法如下:

/cardview-bread-initstatic.png
/cardview-bread-initstatic.png

可以看到它们的实现方法,差异还是挺大的。

了解清楚这些,我们只需要 RoundRectDrawableWithShadow 的构造方法中,根据 Api Level 对他们进行不同的初始化即可,这些代码也上拷贝出来就可以直接用的。

绘制阴影的部分都大同小异,这里就不详细看了,有兴趣的可以执行查看源码,主要关注 drawShadow() 方法即可。

6.2 举个 CardView 阴影的例子

首先,将 ShadowDrawableWrapper 完整的拷贝到我们的工程里,并且在构造方法中,根据 Api Level ,用不同的逻辑初始化 sRoundRectHelper 。

还需要将 ShadowDrawableWrapper 使用到的几个默认参数值也拷贝出来,当然我们已经有源码了,直接写死也可以,我这里选择将它们原样拷贝出来。

/cardview-dims.png
/cardview-dims.png

然后我们就可以在代码中,使用这个 RoundRectDrawableWithShadow 了。

/cardview-javacode.png
/cardview-javacode.png

最终,看看实现的阴影效果:

/cardview-xiaoguo.png
/cardview-xiaoguo.png

6.3 CardView 模拟阴影小结

CardView 模拟的阴影效果,在低版本上,也上会占用 View 的原本的大小来绘制阴影,所以视觉上也会偏小。不过在高版本上,依然上使用 elevation来实现的,也就会造成在不同 Api Level 下,显示的效果不一致的问题。

七、使用开源库 ShadowLayout

最后再介绍一个开源库,用一个 LayoutView 来实现阴影的效果。

Github 地址:

github.com/dmytrodanyl…

它完整的库也只有一个类加一些属性,整个项目结构如下。

/shadowlayout-project.png
/shadowlayout-project.png

并且提供了几个属性,用于配置阴影的效果。

/shadowlayout-res.png
/shadowlayout-res.png

使用起来也非常的方便,它上直接继承自 FrameLayout 的,所以需要作为一个布局来使用。

/shadowlayout-xml.png
/shadowlayout-xml.png

最后看看实现的效果。

/shadowlayout-xiaoguo.png
/shadowlayout-xiaoguo.png

它基本上可以实现一个类阴影的效果,不过应该是算法的问题,导致阴影的边缘太齐了,看着不真实,一般不推荐使用。

八、结语

介绍了这么多在 Android 下实现阴影的效果,接下来给一张完整的效果图吧,如果本文都看完了,我想你应该知道自己应该选择那种方案了。

/WechatIMG30.jpeg
/WechatIMG30.jpeg

今天在承香墨影公众号的后台,回复『成长』。我会送你一些我整理的学习资料,包含:Android反编译、算法、设计模式、Web项目源码。

推荐阅读:

点赞或者分享吧~