开箱即用的源码地址
支持自定义属性:
sl_shadowRadius
:阴影的发散距离sl_shadowColor
:阴影的颜色sl_dx
:阴影左右偏移量sl_dy
:阴影上下偏移量sl_cornerRadius
:布局圆角sl_borderWidth
:布局边框宽度sl_borderColor
:布局边框颜色sl_shadowSides
:在某条边或多条边显示阴影
起源
近几个月来我司画家们(设计大佬)越来越多的开始使用阴影,所以也就不能再使用.9.png
的实现方式了,然后就有了这次封装的ShadowLayout
,其主要特点是:
- 提取Layout的自定义属性,对使用者来讲可快速上手
- UI表现细腻,还原度高
但依然有个避不过的缺点,就是阴影区域占用了Layout的Padding区域,需要使用者心算Layout的实际宽高,虽然计算很简单…
头图是这次Demo演示了三种场景,然后结合局部UI稿,大家可以对比看下。
思考分析
我们先来思考下实现的关键点:
- 为了
write once use everywhere
我们就写成一个布局,这样可以想包裹什么包什么,所以定义一个继承FrameLayout
的布局,取名ShadowLayout
- 核心在于实现
阴影效果
,查资料了解到可以使用Paint
的setShadowLayer()
API - 圆角的处理,我们可以使用
xfermode
的相关模式,对画布上的子View进行一个去圆角合成 - 边框的处理很容易,使用
Canvas
的drawRoundRect
画就可以 - 控制某条边或多条边显示阴影,这个使用自定义属性的
flags
类型实现(恰如其分的符合我们的需求)
思路框架:
- 定义、初始化属性
- 设置padding为阴影留出空间
- 绘制内容区域大小的阴影(内容区域==子View占用的区域==Layout大小-padding)
- 绘制内容区域、处理圆角
- 绘制边框
技术点、思路理好了,就着手开始代码了,其中还是有一些细节知识点的,Go ahead!
绘制过程
NOTE:因为经常要自定义View所以把常用的工具方法,使用Kotlin的扩展方法抽取了出来,在DrawUtil.kt
文件。
比如mPaint.utilReset()
,是扩展出来的方法,而不是Paint类
的API。
1. 定义、初始化属性
第一步比较基础,在attrs.xml
中定义我们的属性,在Layout中声明变量,并做初始化。
其中着重说下sl_shadowSides
:
- 它的类型是
flags
并且是复数形式,所以这个可以用来为某个属性设置多个标志位 - 具体在xml布局中的使用方式是
app:sl_shadowSides="TOP|RIGHT|BOTTOM"
,通过|(逻辑或)
连接多个标志位(这种方式其实我们经常用) - 定义的value值
1、2、4、8、15
是有规律的,不是随便设的 - 在代码中使用时会涉及到
- 判断
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
//********************************
//* 绘制使用的属性部分
//********************************
/** * 全局画笔 */
private var mPaint: Paint = createPaint(color = Color.WHITE)
private var mHelpPaint: Paint = createPaint(color = Color.RED)
/** * 全局Path */
private var mPath = Path()
/** * 合成模式 */
private var mXfermode: PorterDuffXfermode by Delegates.notNull()
/** * 视图内容区域的RectF实例 */
private var mContentRF: RectF by Delegates.notNull()
/** * 视图边框的RectF实例 */
private var mBorderRF: RectF? = null
复制代码
init {
initAttributes(context, attrs)
initDrawAttributes()
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()
}
}
复制代码
/** * 初始化绘制相关的属性 */
private fun initDrawAttributes() {
//使用xfermode在图层上进行合成,处理圆角
mXfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OUT)
}
复制代码
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))
这里可以抛两个小疑问:
- 为什么要占用Layout的padding呢?而不使用去除padding后的区域空间
- 为什么在上下或左右都显示阴影的情况时,上下或左右都要设置
(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,就不绘制该图层。 * * Can be used to create a blurred shadow underneath text. Support for use * with other drawing operations is constrained to the software rendering * pipeline. * 可以用来在文本下方创建模糊阴影。 * 也支持其他的绘图操作,但必须设置为软件渲染类型。 *
* 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()
)
//以边框宽度的三分之一,微调边框绘制位置,以在边框较宽时得到更好的视觉效果
val bw = mBorderWidth / 3
if (bw > 0) {
mBorderRF = RectF(
mContentRF.left + bw,
mContentRF.top + bw,
mContentRF.right - bw,
mContentRF.bottom - bw
)
}
}
复制代码
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构建四个圆角
mPath = mPath.apply {
addRect(
mContentRF,
Path.Direction.CW
)
addRoundRect(
mContentRF,
mCornerRadius,
mCornerRadius,
Path.Direction.CW
)
fillType = Path.FillType.EVEN_ODD
}
//使用xfermode在图层上进行合成,处理圆角
mPaint.xfermode = mXfermode
canvas.drawPath(mPath, mPaint)
mPaint.utilReset()
mPath.reset()
canvas.restore()
}
复制代码
绘制过程是:
- 开启一个新的图层
- 将子View绘制上去,作为
xfermode
合成模式的目标 - 使用Path构建四个圆角,作为合成模式的源
- 用
DST_OUT(去除目标)
模式合成
再来张效果图,如下:
布局中属性值为app:sl_cornerRadius="10dp"
5. 绘制边框
这一步也就简单了,上代码:
override fun dispatchDraw(canvas: Canvas?) {
...//略去代码
//绘制边框
drawBorder(canvas)
}
复制代码
private fun drawBorder(canvas: Canvas) {
mBorderRF?.let {
canvas.save()
mPaint.strokeWidth = mBorderWidth
mPaint.style = Paint.Style.STROKE
mPaint.color = mBorderColor
canvas.drawRoundRect(it, mCornerRadius, mCornerRadius, mPaint)
mPaint.utilReset()
canvas.restore()
}
}
复制代码
最后的效果图,如下:
布局中属性值为app:sl_borderWidth="2dp"
文末
个人能力有限,如有不正之处欢迎大家批评指出,我会虚心接受并第一时间修改,以不误导大家。
拜读的文章
我的其它文章
转载于:https://juejin.im/post/5cca93def265da038145e742