查看原文
其他

设计说Android自带的阴影效果太丑了,我们自定义走起

二娃_ 郭霖 2020-10-29


/   今日科技快讯   /


近日美团公布2019年第一季度业绩:集团营业收入同比增长70.1%达191.7亿元。本季度,美团经营亏损为13.04亿元人民币,同比增长24.3%;经调整亏损净额为10.39亿元人民币,同比增长6%。


当季,美团总交易金额增长27.9%达1384亿元,年度交易用户达4.1亿,较去年同期大幅增长8600万,平均每位交易用户每年交易笔数较上年同期增长至24.8笔。第一季度,美团的活跃商家达580万。


/   作者简介   /


明天就是周六啦,提前祝大家周末愉快!


本篇文章来自二娃_的投稿,分享了一款很棒的阴影控件,希望对大家有所帮助!同时也感谢作者贡献的精彩文章。


二娃_的博客地址:

https://juejin.im/user/5bf67660e51d45218f3d0938


/   起源  /


近几个月来我司画家们(设计大佬)越来越多的开始使用阴影,所以也就不能再使用.9.png的实现方式了,然后就有了这次封装的ShadowLayout,其主要特点是:


1. 提取Layout的自定义属性,对使用者来讲可快速上手

2. UI表现细腻,还原度高


但依然有个避不过的缺点,就是阴影区域占用了Layout的Padding区域,需要使用者心算Layout的实际宽高,虽然计算很简单...


头图是这次Demo演示了三种场景,然后结合局部UI稿,大家可以对比看下。





/   思考分析  /


我们先来思考下实现的关键点:


  • 为了write once use everywhere我们就写成一个布局,这样可以想包裹什么包什么,所以定义一个继承FrameLayout的布局,取名ShadowLayout

  • 核心在于实现阴影效果,查资料了解到可以使用Paint的setShadowLayer()API

  • 圆角的处理,我们可以使用xfermode的相关模式,对画布上的子View进行一个去圆角合成

  • 边框的处理很容易,使用Canvas的drawRoundRect画就可以

  • 控制某条边或多条边显示阴影,这个使用自定义属性的flags类型实现(恰如其分的符合我们的需求)


思路框架:


  1. 定义、初始化属性

  2. 设置padding为阴影留出空间

  3. 绘制内容区域大小的阴影(内容区域==子View占用的区域==Layout大小-padding)

  4. 绘制内容区域、处理圆角

  5. 绘制边框


技术点、思路理好了,就着手开始代码了,其中还是有一些细节知识点的,Go ahead!


/   绘制过程  /


NOTE:因为经常要自定义View所以把常用的工具方法,使用Kotlin的扩展方法抽取了出来,在DrawUtil.kt文件。


比如mPaint.utilReset(),是扩展出来的方法,而不是Paint类的API。


源码地址-DrawUtil.kt

https://github.com/drawf/SourceSet/blob/master/app/src/main/java/me/erwa/sourceset/view/DrawUtil.kt


1. 定义、初始化属性


第一步比较基础,在attrs.xml中定义我们的属性,在Layout中声明变量,并做初始化。


其中着重说下sl_shadowSides:


  1. 它的类型是flags并且是复数形式,所以这个可以用来为某个属性设置多个标志位

  2. 具体在xml布局中的使用方式是app:sl_shadowSides="TOP|RIGHT|BOTTOM",通过|(逻辑或)连接多个标志位(这种方式其实我们经常用)

  3. 定义的value值1、2、4、8、15是有规律的,不是随便设的

  4. 在代码中使用时会涉及到

  • 判断flag集是否包含某个flag(本次用到)

  • 在flag集中添加新flag

  • 在flag集中去除某flag


所以也在DrawUtil中扩展了相关方法,便于复用。


而关于这部分的原理,我在文末参考的文章中给出了链接,大家自行食用。


贴一大波初始化相关代码,如下:


<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="ShadowLayout">
        <attr name="sl_cornerRadius" format="dimension" />
        <attr name="sl_shadowRadius" format="dimension" />
        <attr name="sl_shadowColor" format="color" />
        <attr name="sl_dx" format="dimension" />
        <attr name="sl_dy" format="dimension" />
        <attr name="sl_borderColor" format="color" />
        <attr name="sl_borderWidth" format="dimension" />
        <attr name="sl_shadowSides" format="flags">
            <flag name="TOP" value="1" />
            <flag name="RIGHT" value="2" />
            <flag name="BOTTOM" value="4" />
            <flag name="LEFT" value="8" />
            <flag name="ALL" value="15" />
        </attr>
    </declare-styleable>
</resources>


/**
 * 阴影颜色
 */
@ColorInt
private var mShadowColor: Int = 0
/**
 * 阴影发散距离 blur
 */
private var mShadowRadius: Float = 0f
/**
 * x轴偏移距离
 */
private var mDx: Float = 0f
/**
 * y轴偏移距离
 */
private var mDy: Float = 0f
/**
 * 圆角半径
 */
private var mCornerRadius: Float = 0f
/**
 * 边框颜色
 */
@ColorInt
private var mBorderColor: Int = 0
/**
 * 边框宽度
 */
private var mBorderWidth: Float = 0f
/**
 * 控制四边是否显示阴影
 */
private var mShadowSides: Int = default_shadowSides


init {
    initAttributes(context, attrs)
    processPadding()
    //设置软件渲染类型,跟绘制阴影相关,后边会说
    setLayerType(View.LAYER_TYPE_SOFTWARE, null)
}


companion object {
    const val debug = true

    private const val FLAG_SIDES_TOP = 1
    private const val FLAG_SIDES_RIGHT = 2
    private const val FLAG_SIDES_BOTTOM = 4
    private const val FLAG_SIDES_LEFT = 8
    private const val FLAG_SIDES_ALL = 15

    const val default_shadowColor = Color.BLACK
    const val default_shadowRadius = 0f
    const val default_dx = 0f
    const val default_dy = 0f
    const val default_cornerRadius = 0f
    const val default_borderColor = Color.RED
    const val default_borderWidth = 0f
    const val default_shadowSides = FLAG_SIDES_ALL
}
复制代码private fun initAttributes(context: Context, attrs: AttributeSet?) {
    val a = context.obtainStyledAttributes(attrs, R.styleable.ShadowLayout)
    try {
        a?.run {
            mShadowColor = getColor(R.styleable.ShadowLayout_sl_shadowColor, default_shadowColor)
            mShadowRadius =
                getDimension(R.styleable.ShadowLayout_sl_shadowRadius, context.dpf2pxf(default_shadowRadius))
            mDx = getDimension(R.styleable.ShadowLayout_sl_dx, default_dx)
            mDy = getDimension(R.styleable.ShadowLayout_sl_dy, default_dy)

            mCornerRadius =
                getDimension(R.styleable.ShadowLayout_sl_cornerRadius, context.dpf2pxf(default_cornerRadius))
            mBorderColor = getColor(R.styleable.ShadowLayout_sl_borderColor, default_borderColor)
            mBorderWidth =
                getDimension(R.styleable.ShadowLayout_sl_borderWidth, context.dpf2pxf(default_borderWidth))

            mShadowSides = getInt(R.styleable.ShadowLayout_sl_shadowSides, default_shadowSides)
        }
    } finally {
        a?.recycle()
    }
}


2. 设置padding为阴影留出空间


private fun processPadding() {
    val xPadding = (mShadowRadius + mDx.absoluteValue).toInt()
    val yPadding = (mShadowRadius + mDy.absoluteValue).toInt()

    setPadding(
        if (mShadowSides.containsFlag(FLAG_SIDES_LEFT)) xPadding else 0,
        if (mShadowSides.containsFlag(FLAG_SIDES_TOP)) yPadding else 0,
        if (mShadowSides.containsFlag(FLAG_SIDES_RIGHT)) xPadding else 0,
        if (mShadowSides.containsFlag(FLAG_SIDES_BOTTOM)) yPadding else 0
    )
}


这里是倒推出使用者需要在布局时心算一下布局实际大小的地方。


NOTE:

  • ShadowLayout实际宽度=内容区域宽度+(mShadowRadius + Math.abs(mDx))*2

  • ShadowLayout实际高度=内容区域高度+(mShadowRadius + Math.abs(mDy))*2

  • 只设置一边显示阴影时,阴影部分占用的大小是(mShadowRadius + Math.abs(mDx、mDy))


这里可以抛两个小疑问:


  1. 为什么要占用Layout的padding呢?而不使用去除padding后的区域空间

  2. 为什么在上下或左右都显示阴影的情况时,上下或左右都要设置(mShadowRadius + Math.abs(mDx)的padding距离?(因为偏移量的存在,向一边偏移时,另一边并不需要那么大的空间)


其实原因就是:为了让使用者更简单的计算布局实际大小,同时也省去了需计算传给子View的Canvas大小的麻烦


Tips:Android的View系统中dispatchDraw(canvas: Canvas?)(仅以该方法做代表),canvas的宽高是不包含父View的padding的区域的。


3. 绘制内容区域大小的阴影


这里之所以叫做「绘制内容区域大小的阴影」,是因为我们要根据内容区域的大小结合Paint的setShadowLayer()、Canvas的drawRoundRect()来绘制出一个带阴影的圆角矩形。


然后子View是绘制在该矩形之上,且贴合内容区域大小,视觉上就仿佛子View有了阴影一样。


前文提到setLayerType(View.LAYER_TYPE_SOFTWARE, null),为什么我们要设置为软件渲染类型呢?看下该方法的源码便知。


Tips:关于setLayerType()更多知识,见我拜读的文章


/**
 * This draws a shadow layer below the main layer, with the specified
 * offset and color, and blur radius. If radius is 0, then the shadow
 * layer is removed.
 * 该方法使用指定的偏移值、颜色和发散距离在主图层下绘制一个阴影图层。
 * 如果发散距离为0,就不绘制该图层。
 * <p>
 * Can be used to create a blurred shadow underneath text. Support for use
 * with other drawing operations is constrained to the software rendering
 * pipeline.
 * 可以用来在文本下方创建模糊阴影。
 * 也支持其他的绘图操作,但必须设置为软件渲染类型。
 * <p>
 * The alpha of the shadow will be the paint's alpha if the shadow color is
 * opaque, or the alpha from the shadow color if not.
 * 如果shadowColor是不透明的(alpha通道值为255),
 * 那么就使用画笔的不透明度,否则就使用该值作为透明度。
 */
public void setShadowLayer(float radius, float dx, float dy, int shadowColor) {
  mShadowLayerRadius = radius;
  mShadowLayerDx = dx;
  mShadowLayerDy = dy;
  mShadowLayerColor = shadowColor;
  nSetShadowLayer(mNativePaint, radius, dx, dy, shadowColor);
}


绘制阴影相关的代码如下:


//计算内容区域的大小
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
    super.onSizeChanged(w, h, oldw, oldh)
    mContentRF = RectF(
        paddingLeft.toFloat(),
        paddingTop.toFloat(),
        (w - paddingRight).toFloat(),
        (h - paddingBottom).toFloat()
    )
}


override fun dispatchDraw(canvas: Canvas?) {
    if (canvas == null) return

    canvas.helpGreenCurtain(debug)

    //绘制阴影
    drawShadow(canvas)

    //绘制子View,后边会说
    drawChild(canvas) {
        super.dispatchDraw(it)
    }

    //绘制边框,后边会说
    drawBorder(canvas)
}


private fun drawShadow(canvas: Canvas) {
    canvas.save()

    mPaint.setShadowLayer(mShadowRadius, mDx, mDy, mShadowColor)
    canvas.drawRoundRect(mContentRF, mCornerRadius, mCornerRadius, mPaint)
    mPaint.utilReset()

    canvas.restore()
}


贴张图看下效果:


布局中属性值为app:sl_shadowRadius="12dp"



4. 绘制内容区域、处理圆角


这里先看代码再做解释,如下:


override fun dispatchDraw(canvas: Canvas?) {
    ...//略去代码

    //绘制子View
    drawChild(canvas) {
        super.dispatchDraw(it)
    }

    ...//略去代码
}


private fun drawChild(canvas: Canvas, block: (Canvas) -> Unit) {
    canvas.saveLayer(0f, 0f, canvas.width.toFloat(), canvas.height.toFloat(), mPaint, Canvas.ALL_SAVE_FLAG)

    //先绘制子控件
    block.invoke(canvas)

    //使用path构建四个圆角
    val path = Path().apply {
        addRect(
            mContentRF,
            Path.Direction.CW
        )
        addRoundRect(
            mContentRF,
            mCornerRadius,
            mCornerRadius,
            Path.Direction.CW
        )
        fillType = Path.FillType.EVEN_ODD
    }

    //使用xfermode在图层上进行合成,处理圆角
    mPaint.xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OUT)
    canvas.drawPath(path, mPaint)
    mPaint.utilReset()

    canvas.restore()
}


绘制过程是:


  1. 开启一个新的图层

  2. 将子View绘制上去,作为xfermode合成模式的目标

  3. 使用Path构建四个圆角,作为合成模式的源

  4. 用DST_OUT(去除目标)模式合成


再来张效果图,如下:


布局中属性值为app:sl_cornerRadius="10dp"



5. 绘制边框


这一步也就简单了,上代码:


override fun dispatchDraw(canvas: Canvas?) {
    ...//略去代码

    //绘制边框
    drawBorder(canvas)
}


private fun drawBorder(canvas: Canvas) {
    //以边框宽度的三分之一,微调边框绘制位置,以在边框较宽时得到更好的视觉效果
    val bw = mBorderWidth / 3
    if (bw > 0) {
        canvas.save()

        val borderRF = RectF(
            mContentRF.left + bw,
            mContentRF.top + bw,
            mContentRF.right - bw,
            mContentRF.bottom - bw
        )
        mPaint.strokeWidth = mBorderWidth
        mPaint.style = Paint.Style.STROKE
        mPaint.color = mBorderColor
        canvas.drawRoundRect(borderRF, mCornerRadius, mCornerRadius, mPaint)
        mPaint.utilReset()

        canvas.restore()
    }
}


最后的效果图,如下:


布局中属性值为app:sl_borderWidth="2dp"



/   文末  /


个人能力有限,如有不正之处欢迎大家批评指出,我会虚心接受并第一时间修改,以不误导大家。


拜读的文章


细谈 Android 中的 attributes 属性标志

https://www.jianshu.com/p/045c8529b9c6



Android 自定义 View 1-8 硬件加速

https://hencoder.com/ui-1-8/


源码地址


https://github.com/drawf/SourceSet/blob/master/app/src/main/java/me/erwa/sourceset/view/layout/ShadowLayout.kt


推荐阅读:

面试加分项:RecyclerView全面的源码解析

一篇文章带你看遍Google I/O 2019大会

程序媛说源码:AsyncTask在子线程创建与调用


欢迎关注我的公众号

学习技术或投稿



长按上图,识别图中二维码即可关注


    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存