【自定義View】洋蔥數學同款陰影佈局-ShadowLayout

開箱即用的源碼地址

洋蔥數學同款陰影佈局-ShadowLayoutphp

支持自定義屬性:java

  • sl_shadowRadius:陰影的發散距離
  • sl_shadowColor:陰影的顏色
  • sl_dx:陰影左右偏移量
  • sl_dy:陰影上下偏移量
  • sl_cornerRadius:佈局圓角
  • sl_borderWidth:佈局邊框寬度
  • sl_borderColor:佈局邊框顏色
  • sl_shadowSides:在某條邊或多條邊顯示陰影

起源

近幾個月來我司畫家們(設計大佬)愈來愈多的開始使用陰影,因此也就不能再使用.9.png的實現方式了,而後就有了此次封裝的ShadowLayout,其主要特色是:git

  1. 提取Layout的自定義屬性,對使用者來說可快速上手
  2. UI表現細膩,還原度高

但依然有個避不過的缺點,就是陰影區域佔用了Layout的Padding區域,須要使用者心算Layout的實際寬高,雖然計算很簡單...github

頭圖是此次Demo演示了三種場景,而後結合局部UI稿,你們能夠對比看下。canvas

思考分析

咱們先來思考下實現的關鍵點:app

  • 爲了write once use everywhere咱們就寫成一個佈局,這樣能夠想包裹什麼包什麼,因此定義一個繼承FrameLayout的佈局,取名ShadowLayout
  • 核心在於實現陰影效果,查資料瞭解到可使用PaintsetShadowLayer()API
  • 圓角的處理,咱們可使用xfermode的相關模式,對畫布上的子View進行一個去圓角合成
  • 邊框的處理很容易,使用CanvasdrawRoundRect畫就能夠
  • 控制某條邊或多條邊顯示陰影,這個使用自定義屬性的flags類型實現(恰如其分的符合咱們的需求)

思路框架:框架

  1. 定義、初始化屬性
  2. 設置padding爲陰影留出空間
  3. 繪製內容區域大小的陰影(內容區域==子View佔用的區域==Layout大小-padding)
  4. 繪製內容區域、處理圓角
  5. 繪製邊框

技術點、思路理好了,就着手開始代碼了,其中仍是有一些細節知識點的,Go ahead!ide

繪製過程

NOTE:由於常常要自定義View因此把經常使用的工具方法,使用Kotlin的擴展方法抽取了出來,在DrawUtil.kt文件。工具

好比mPaint.utilReset(),是擴展出來的方法,而不是Paint類的API。佈局

源碼地址-DrawUtil.kt

1. 定義、初始化屬性

第一步比較基礎,在attrs.xml中定義咱們的屬性,在Layout中聲明變量,並作初始化。

其中着重說下sl_shadowSides

  1. 它的類型是flags而且是複數形式,因此這個能夠用來爲某個屬性設置多個標誌位
  2. 具體在xml佈局中的使用方式是app:sl_shadowSides="TOP|RIGHT|BOTTOM",經過|(邏輯或)鏈接多個標誌位(這種方式其實咱們常常用)
  3. 定義的value值一、二、四、八、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

//********************************
//* 繪製使用的屬性部分
//********************************

/** * 全局畫筆 */
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))

這裏能夠拋兩個小疑問:

  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()
    )
    
    //以邊框寬度的三分之一,微調邊框繪製位置,以在邊框較寬時獲得更好的視覺效果
    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()
}
複製代碼

繪製過程是:

  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) {
    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"

文末

我的能力有限,若有不正之處歡迎你們批評指出,我會虛心接受並第一時間修改,以不誤導你們

拜讀的文章

個人其它文章

相關文章
相關標籤/搜索