我们知道,Google 在 2014 年 I/O大会上发布的一种新的设计规范——Material Design,这种设计规范给 Android UI 设计带来了很多的变化。比如,更加强调真实性、有立体感,由此引发的一系列针对阴影的UI设计。我相信,很多专注业务逻辑的Android程序员,拿着这样的一个UI效果,往往一头雾水。所以,我仅在此抛砖引玉,希望能够打开思路,更好的满足UI的要求。

好的,不多说废话,下面先来看看几种实现阴影的方式,先有个直观印象,后面挨个说明(文末有几种实现方式的效果对比图):

Android 画少一边框的矩形 android边框阴影效果_Z轴

1、layer-list方式

这种方式稍微复杂点,不过对shape熟悉的话,那也是分分钟的事。

Android 画少一边框的矩形 android边框阴影效果_Z轴_02

  • 首先需要自定义drawable文件——shadow_demo.xml,并在shape样式中由浅入深指定阴影的颜色、样式等。文件存放路径:res--drawable--shadow_demo.xml
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
    <!-- 边框阴影 -->
    <item>
        <shape android:shape="rectangle">
            <padding
                android:bottom="2dp"
                android:left="2dp"
                android:right="2dp"
                android:top="2dp" />
            <stroke
                android:width="3px"
                android:color="#00FF2121" />
        </shape>
    </item>
    <item>
        <shape android:shape="rectangle">
            <padding
                android:bottom="2dp"
                android:left="2dp"
                android:right="2dp"
                android:top="2dp" />
            <stroke
                android:width="3px"
                android:color="#10FF2121" />
        </shape>
    </item>
    <item>
        <shape android:shape="rectangle">
            <padding
                android:bottom="2dp"
                android:left="2dp"
                android:right="2dp"
                android:top="2dp" />
            <stroke
                android:width="3px"
                android:color="#20FF2121" />
        </shape>
    </item>
    <item>
        <shape android:shape="rectangle">
            <padding
                android:bottom="2dp"
                android:left="2dp"
                android:right="2dp"
                android:top="2dp" />
            <stroke
                android:width="3px"
                android:color="#30FF2121" />
        </shape>
    </item>
    <item>
        <shape android:shape="rectangle">
            <padding
                android:bottom="2dp"
                android:left="2dp"
                android:right="2dp"
                android:top="2dp" />
            <stroke
                android:width="3px"
                android:color="#50FF2121" />
        </shape>
    </item>

    <!-- 中心背景 -->
    <item>
        <shape
            android:shape="rectangle"
            android:useLevel="false">
            <stroke
                android:width="3px"
                android:color="#64FF2121" />
            <padding
                android:bottom="10dp"
                android:left="10dp"
                android:right="10dp"
                android:top="10dp" />
        </shape>
    </item>
</layer-list>
  • 然后当作View的背景阴影,即可实现阴影效果。
<LinearLayout
        android:layout_width="wrap_content"
        android:layout_height="match_parent"
        android:layout_marginStart="50dp"
        android:gravity="center"
        android:orientation="vertical"
        tools:ignore="RtlCompat">

        <com.tcl.uicompat.TCLTextView
            style="@style/TextBody3"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="layer-list实现"
            tools:ignore="HardcodedText" />

        <View
            android:layout_width="200dp"
            android:layout_height="200dp"
            android:layout_marginTop="50dp"
            android:background="@drawable/shadow_demo" />
    </LinearLayout>

2、.9图片

这个方式算是比较简单,和自定义的drawable差不多,UI切好图放做背景即可,就不多说了。

相比自定义的drawable,我们只是把作图的行为放到了UI切图部分而已,整体工作量并未减少。

3、Z轴elevation实现

这是最简单的实现方式,只需要xml布局中的一个elevation属性就可以做到,如果要做作动画阴影效果,可以使用TranslationZ动态海拔高度偏移高度,是一个偏移的距离。Z轴有如下公式:

Z = elevation + translationZ

Android 画少一边框的矩形 android边框阴影效果_Android 画少一边框的矩形_03

<LinearLayout
        android:layout_width="wrap_content"
        android:layout_height="match_parent"
        android:layout_marginStart="100dp"
        android:gravity="center"
        android:orientation="vertical"
        tools:ignore="RtlCompat">

        <com.tcl.uicompat.TCLTextView
            style="@style/TextBody3"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="Z轴elevation实现"
            android:textColor="#FF000000"
            tools:ignore="HardcodedText" />

        <View
            android:layout_width="200dp"
            android:layout_height="200dp"
            android:layout_marginTop="50dp"
            android:background="@drawable/shadow_background_1"
            android:elevation="10dp"
            tools:targetApi="lollipop" />
    </LinearLayout>

shadow_background_1.xml文件存放路径:res--drawable--shadow_background_1.xml,代码如下:

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
    <solid android:color="#FFE1E2EB" />
    <corners android:radius="8px" />
</shape>

不过使用Z轴实现阴影有几点需要注意:

  • 如果View的父布局宽度是wrap_content或固定值,需要在顶层父布局中添加android:clipChildren="false",避免阴影显示不全;
  • elevation属性在API21以上才能使用,而且它的值不能设置太大,否则物极必反,导致不显示阴影;
  • 视图的阴影一定是由有轮廓的视图投射出来的。简单来说,就是需要设置控件的背景,即 android:background 属性。我们可以选择图片作为背景,也可以使用shape定义一个 drawable 形状。
  • elevation的阴影位置,与作用的View在界面的位置相关,下图可对比看看差别:

PS:使用完全一样的View,在界面不同位置,阴影不一样。相当于在Y为0,正上方Z轴的光源,向左、向右、向下照射而形成的阴影。

Android 画少一边框的矩形 android边框阴影效果_Z轴_04

总的来说: Android原生实现的阴影是比较好的,推荐使用。但是有些特殊UI效果需要改变阴影位置和颜色、透明度,Z轴的方式目前是做不到(当然API28可以一试),所以需要更多的方式来实现阴影。

4、CardView包裹实现

我们知道CardView可以实现圆角和阴影,当然CardView不支持改变颜色、位置,因为实现方式的原因,如下见解如有误还望读者提出:

  • API21之前使用的drawpath实现阴影,而硬件加速不支持drawpath,所以应该是开启了软解的,并写死了灰色,且不提供public api供外接访问。
  • API21之后实现应该不用设置layertype,默认硬件加速就可以,因为使用的Z轴实现。

具体实现就不贴出了,有如下属性可以使用:

Android 画少一边框的矩形 android边框阴影效果_Android 画少一边框的矩形_05

5、自定义ViewGroup实现

这种方式是顺着CardView的思路去实现的,因为CardView没有暴露出颜色、位置的API,那我们就可以自己去参照实现,依赖View提供的API——setShadowLayer()。

这种方式稍微复杂些,会涉及到自定义属性,自定义ViewGroup,但写好了就可以一劳永逸,值得试试。效果如图:

Android 画少一边框的矩形 android边框阴影效果_android_06

(1)自定义ShadowContainer文件,继承自ViewGroup:

package com.tcl.uicompat.util;

import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.RectF;
import android.os.Build;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;

import com.tcl.uicompat.R;

/**
 * Created by Agg on 2020/03/05.
 * Tips: Implemented based on packaging.
 * Reference: https://github.com/cjlemon/Shadow
 */
public class ShadowContainer extends ViewGroup {

    private final float deltaLength;
    private final float cornerRadius;
    private final Paint mShadowPaint;
    private boolean drawShadow;

    public ShadowContainer(Context context) {
        this(context, null);
    }

    public ShadowContainer(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public ShadowContainer(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ShadowContainer);
        int shadowColor = a.getColor(R.styleable.ShadowContainer_containerShadowColor, Color.RED);
        float shadowRadius = a.getDimension(R.styleable.ShadowContainer_containerShadowRadius, 0);
        deltaLength = a.getDimension(R.styleable.ShadowContainer_containerDeltaLength, 0);
        cornerRadius = a.getDimension(R.styleable.ShadowContainer_containerCornerRadius, 0);
        float dx = a.getDimension(R.styleable.ShadowContainer_deltaX, 0);
        float dy = a.getDimension(R.styleable.ShadowContainer_deltaY, 0);
        drawShadow = a.getBoolean(R.styleable.ShadowContainer_enable, true);
        a.recycle();
        mShadowPaint = new Paint();
        mShadowPaint.setStyle(Paint.Style.FILL);
        mShadowPaint.setAntiAlias(true);
        mShadowPaint.setColor(shadowColor);
        mShadowPaint.setShadowLayer(shadowRadius, dx, dy, shadowColor);
    }

    @Override
    protected void dispatchDraw(Canvas canvas) {
        if (drawShadow) {
            /*
                setShadowLayer()/setMaskFilter is not support hardware acceleration, so using LAYER_TYPE_SOFTWARE, but software layers isn't always good.
                LAYER_TYPE_SOFTWARE: software layers should be avoided when the affected view tree updates often.
             */
            if (getLayerType() != LAYER_TYPE_SOFTWARE) {
                setLayerType(LAYER_TYPE_SOFTWARE, null);
            }
            View child = getChildAt(0);
            int left = child.getLeft();
            int top = child.getTop();
            int right = child.getRight();
            int bottom = child.getBottom();
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
                canvas.drawRoundRect(left, top, right, bottom, cornerRadius, cornerRadius, mShadowPaint);
            } else {
                Path drawablePath = new Path();
                drawablePath.moveTo(left + cornerRadius, top);
                drawablePath.arcTo(new RectF(left, top, left + 2 * cornerRadius, top + 2 * cornerRadius), -90, -90, false);
                drawablePath.lineTo(left, bottom - cornerRadius);
                drawablePath.arcTo(new RectF(left, bottom - 2 * cornerRadius, left + 2 * cornerRadius, bottom), 180, -90, false);
                drawablePath.lineTo(right - cornerRadius, bottom);
                drawablePath.arcTo(new RectF(right - 2 * cornerRadius, bottom - 2 * cornerRadius, right, bottom), 90, -90, false);
                drawablePath.lineTo(right, top + cornerRadius);
                drawablePath.arcTo(new RectF(right - 2 * cornerRadius, top, right, top + 2 * cornerRadius), 0, -90, false);
                drawablePath.close();
                canvas.drawPath(drawablePath, mShadowPaint);
            }
        }
        super.dispatchDraw(canvas);
    }

    /**
     * setMeasuredDimension(): store the modified width and modified height.
     *
     * @param widthMeasureSpec  the original width
     * @param heightMeasureSpec the original height
     */
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        if (getChildCount() != 1) {
            throw new IllegalStateException("Child View can have only one!!!");
        }
        int measuredWidth = getMeasuredWidth();
        int measuredHeight = getMeasuredHeight();
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        View child = getChildAt(0);
        MarginLayoutParams layoutParams = (MarginLayoutParams) child.getLayoutParams();
        int childBottomMargin = (int) (Math.max(deltaLength, layoutParams.bottomMargin) + 1);
        int childLeftMargin = (int) (Math.max(deltaLength, layoutParams.leftMargin) + 1);
        int childRightMargin = (int) (Math.max(deltaLength, layoutParams.rightMargin) + 1);
        int childTopMargin = (int) (Math.max(deltaLength, layoutParams.topMargin) + 1);
        int widthMeasureSpecMode;
        int widthMeasureSpecSize;
        int heightMeasureSpecMode;
        int heightMeasureSpecSize;
        if (widthMode == MeasureSpec.UNSPECIFIED) {
            widthMeasureSpecMode = MeasureSpec.UNSPECIFIED;
            widthMeasureSpecSize = MeasureSpec.getSize(widthMeasureSpec);
        } else {
            if (layoutParams.width == MarginLayoutParams.MATCH_PARENT) {
                widthMeasureSpecMode = MeasureSpec.EXACTLY;
                widthMeasureSpecSize = measuredWidth - childLeftMargin - childRightMargin;
            } else if (MarginLayoutParams.WRAP_CONTENT == layoutParams.width) {
                widthMeasureSpecMode = MeasureSpec.AT_MOST;
                widthMeasureSpecSize = measuredWidth - childLeftMargin - childRightMargin;
            } else {
                widthMeasureSpecMode = MeasureSpec.EXACTLY;
                widthMeasureSpecSize = layoutParams.width;
            }
        }
        if (heightMode == MeasureSpec.UNSPECIFIED) {
            heightMeasureSpecMode = MeasureSpec.UNSPECIFIED;
            heightMeasureSpecSize = MeasureSpec.getSize(heightMeasureSpec);
        } else {
            if (layoutParams.height == MarginLayoutParams.MATCH_PARENT) {
                heightMeasureSpecMode = MeasureSpec.EXACTLY;
                heightMeasureSpecSize = measuredHeight - childBottomMargin - childTopMargin;
            } else if (MarginLayoutParams.WRAP_CONTENT == layoutParams.height) {
                heightMeasureSpecMode = MeasureSpec.AT_MOST;
                heightMeasureSpecSize = measuredHeight - childBottomMargin - childTopMargin;
            } else {
                heightMeasureSpecMode = MeasureSpec.EXACTLY;
                heightMeasureSpecSize = layoutParams.height;
            }
        }
        measureChild(child, MeasureSpec.makeMeasureSpec(widthMeasureSpecSize, widthMeasureSpecMode), MeasureSpec.makeMeasureSpec(heightMeasureSpecSize, heightMeasureSpecMode));
        int parentWidthMeasureSpec = MeasureSpec.getMode(widthMeasureSpec);
        int parentHeightMeasureSpec = MeasureSpec.getMode(heightMeasureSpec);
        int height = measuredHeight;
        int width = measuredWidth;
        int childHeight = child.getMeasuredHeight();
        int childWidth = child.getMeasuredWidth();
        if (parentHeightMeasureSpec == MeasureSpec.AT_MOST) {
            height = childHeight + childTopMargin + childBottomMargin;
        }
        if (parentWidthMeasureSpec == MeasureSpec.AT_MOST) {
            width = childWidth + childRightMargin + childLeftMargin;
        }
        if (width < childWidth + 2 * deltaLength) {
            width = (int) (childWidth + 2 * deltaLength);
        }
        if (height < childHeight + 2 * deltaLength) {
            height = (int) (childHeight + 2 * deltaLength);
        }
        if (height != measuredHeight || width != measuredWidth) {
            setMeasuredDimension(width, height);
        }
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        View child = getChildAt(0);
        int measuredWidth = getMeasuredWidth();
        int measuredHeight = getMeasuredHeight();
        int childMeasureWidth = child.getMeasuredWidth();
        int childMeasureHeight = child.getMeasuredHeight();
        child.layout((measuredWidth - childMeasureWidth) / 2, (measuredHeight - childMeasureHeight) / 2, (measuredWidth + childMeasureWidth) / 2, (measuredHeight + childMeasureHeight) / 2);
    }

    @Override
    protected ViewGroup.LayoutParams generateDefaultLayoutParams() {
        return new MarginLayoutParams(MarginLayoutParams.WRAP_CONTENT, MarginLayoutParams.WRAP_CONTENT);
    }

    @Override
    protected ViewGroup.LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) {
        return new MarginLayoutParams(p);
    }

    @Override
    public ViewGroup.LayoutParams generateLayoutParams(AttributeSet attrs) {
        return new MarginLayoutParams(getContext(), attrs);
    }

    public void setDrawShadow(boolean drawShadow) {
        if (this.drawShadow == drawShadow) {
            return;
        }
        this.drawShadow = drawShadow;
        postInvalidate();
    }

}

(2)自定义属性,在res--values--attrs.xml中添加如下内容:

<declare-styleable name="ShadowContainer">
        <attr name="containerShadowColor" format="color" /><!--阴影颜色-->
        <attr name="containerShadowRadius" format="dimension" /><!--阴影半径-->
        <attr name="containerDeltaLength" format="dimension" /><!--子View到ShadowContainer的距离-->
        <attr name="containerCornerRadius" format="dimension" /><!--子View背景的圆角大小-->
        <attr name="deltaX" format="dimension" />
        <attr name="deltaY" format="dimension" />
        <attr name="enable" format="boolean" />
    </declare-styleable>

(3)layout中使用:

<LinearLayout
        android:layout_width="wrap_content"
        android:layout_height="match_parent"
        android:layout_marginStart="100dp"
        android:gravity="center"
        android:orientation="vertical"
        tools:ignore="RtlCompat">

        <com.tcl.uicompat.TCLTextView
            style="@style/TextBody3"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="自定义ViewGroup实现"
            android:textColor="#FF000000"
            tools:ignore="HardcodedText" />

        <com.tcl.uicompat.util.ShadowContainer
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="50px"
            app:containerCornerRadius="8px"
            app:containerDeltaLength="20px"
            app:containerShadowColor="#ff808080"
            app:containerShadowRadius="20px"
            tools:ignore="PxUsage,RtlHardcoded">

            <View
                android:layout_width="354px"
                android:layout_height="100px"
                android:background="@drawable/shadow_background_1"
                tools:ignore="PxUsage,RtlHardcoded"
                tools:targetApi="lollipop" />
        </com.tcl.uicompat.util.ShadowContainer>

        <com.tcl.uicompat.TCLTextView
            style="@style/TextBody3"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="100px"
            android:text="向左向下偏移5px,颜色变为红色"
            android:textColor="#FF000000"
            tools:ignore="HardcodedText,PxUsage" />

        <com.tcl.uicompat.util.ShadowContainer
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="50px"
            app:containerCornerRadius="8px"
            app:containerDeltaLength="20px"
            app:containerShadowColor="#ff800000"
            app:containerShadowRadius="20px"
            app:deltaX="-5px"
            app:deltaY="5px"
            tools:ignore="PxUsage,RtlHardcoded">

            <View
                android:layout_width="354px"
                android:layout_height="100px"
                android:background="@drawable/shadow_background_1"
                tools:ignore="PxUsage,RtlHardcoded"
                tools:targetApi="lollipop" />
        </com.tcl.uicompat.util.ShadowContainer>
    </LinearLayout>

(4)特别注意:

  • setLayerType开启了软解,通过bitmap缓存阴影,会耗内存,已在代码中注释。
  • 使用的时候,被包裹的View不能有透明度,否则阴影会被显示出来。——这是个坑,需注意。

总结

总的来说,5种方式各有利弊,最推荐的还是Android原生的Z轴实现。

随着Android的发展,各种需求越来越多,我们在解决问题的同时需要知道别人解决此问题的原理,这样才能触类旁通。——硬性总结一把……^ - ^

附件:三种效果图对比

Android 画少一边框的矩形 android边框阴影效果_Android阴影_07