Android 專案中 shape 標籤的整理和思考

咕咚移動技術團隊發表於2018-08-31

作者:咕咚移動技術團隊-Blue

在 Android 開發中,使用 shape 標籤可以很方便的幫我們構建資原始檔,跟傳統的 png 圖片相比:

  • shape 標籤可以幫助我們有效減小 apk 安裝包大小。
  • 在不同手機的適配上面,shape 標籤也表現的更加優秀。

關於 shape 標籤如何使用,在網上一搜一大把,筆者就不在這裡贅述了,今天我們要討論的是 shape 標籤氾濫成災以後帶來的後果。這裡先給大家看一個維護超過了 5 年的專案的 drawable 目錄

image.png
請注意右側標紅的滾動條,有沒有感覺很酸爽,在這個目錄下的檔案現在已經超過了 500 個,並且還在不停的增加。我們分析這個目錄下的 xml 構成,發現主要由兩種型別構成:selector 和 shape。selector 這裡略過不提,重點關注 shape,發現 shape 檔案已經超過了 200 個並且還在不停的增加。我們再帶著好奇的心態隨便點開幾個 shape 看一看

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">

    <solid android:color="#66000000" />
    <corners android:radius="15dp" />

</shape>
複製程式碼
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
    <gradient
        android:startColor="#0f000000"
        android:endColor="#00000000"
        android:angle="270"
        />
</shape>
複製程式碼
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" >

    <solid android:color="#fbfbfd" />
    <stroke
        android:width="1px"
        android:color="#dad9de" />
    
    <corners
        android:radius="10dp" />

</shape>
複製程式碼

真的是不看不知道,一看嚇一跳。原來我們專案中大量存在的 shape 檔案其實都是大同小異的,涉及到最常見的 shape 變化:圓角,描邊,填充以及漸變。 進一步分析,我們又發現:

  • 有些時候填充顏色是相同的,只不過圓角半徑不同,我們就得新增一個 shape 檔案。
  • 有些時候圓角半徑是相同的,只不過填充顏色不同,我們又得新增一個 shape 檔案。
  • 有些時候兩個負責不同業務模組的同事,各自新增一個同樣樣式的 shape 檔案。

等等一些情況,讓我們陷入了 shape 檔案的無限新增與維護中。我們不禁要思考,有沒有辦法可以把這些 shape 統一起來管理呢?xml 書寫出來的程式碼最終不都是會對應一個記憶體中的物件嗎?我們能不能從管理 shape 檔案過度到管理一個物件呢?

Talk is cheap. Show me the code

第一步,我們需要確定 shape 標籤對應的類到底是哪一個?第一反應就是 ShapeDrawable,顧名思義嘛。然後殘酷的事實告訴我們其實是 GradientDrawable 這兄弟。瀏覽 GradientDrawable 類的方法結構,從中我們也找到了setColor()、setCornerRadius()、setStroke() 等目標方法。好吧,不管怎樣,先找到正主了。

第二步,繼續思考如何來設計這個通用控制元件,主要從以下幾個方面進行了考慮:

  • shape 的應用場景有可能是文字標籤,也有可能是響應按鈕,所以需要文字和按鈕兩種樣式,兩者的主要區別在於按鈕樣式在普通狀態下和按壓狀態下都具有陰影。
  • 為了提升使用者體驗,設計了通用控制元件的按壓動效。針對 5.0 以上的使用者開啟按壓水波紋效果,針對 5.0 以下的使用者開啟按壓變色效果。 結合以上兩點,通用控制元件的實現考慮直接繼承 AppCompatButton 進行擴充套件。
  • 具體的業務場景中,通用控制元件的使用還有可能伴隨著 drawable,並且要求 drawable 和文字一起居中顯示。其實這個問題本來是不需要單獨考慮的,但是 Android 有個坑,在一個按鈕控制元件中設定 drawable 以後,預設是貼著控制元件邊緣顯示的,所以這個坑需要單獨填。
  • 自定義控制元件屬性支援 shape 模式、填充顏色、按壓顏色、描邊顏色、描邊寬度、圓角半徑、按壓動效是否開啟、漸變開始顏色、漸變結束顏色、漸變方向、drawable 方位。

第三步,思路已經梳理清楚了,那就開擼。

class CommonShapeButton @JvmOverloads constructor(
        context: Context,
        attrs: AttributeSet? = null,
        defStyleAttr: Int = 0
) : AppCompatButton(context, attrs, defStyleAttr) {
複製程式碼

這裡實現了繼承 AppCompatButton 進行擴充套件,預設樣式 defStyleAttr 傳遞的是 0,那麼 CommonShapeButton 的預設表現形式就是文字樣式。

如果想要採用按鈕樣式,則需要先自定義一個按鈕樣式,原因是系統按鈕的樣式自帶了 minWidth、minHeight 以及 padding,在具體業務中會影響到我們的按鈕顯示,所以在自定義按鈕樣式中重置了這三個屬性:

<!-- 自定義按鈕樣式 -->
<style name="CommonShapeButtonStyle" parent="@style/Widget.AppCompat.Button">
    <item name="android:minWidth">0dp</item>
    <item name="android:minHeight">0dp</item>
    <item name="android:padding">0dp</item>
</style>
複製程式碼

有了自定義按鈕樣式,那麼想要 CommonShapeButton 採用按鈕樣式,則採用如下形式:

<com.blue.view.CommonShapeButton
    style="@style/CommonShapeButtonStyle"
    android:layout_width="300dp"
    android:layout_height="50dp"/>
複製程式碼

到這裡就可以實現簡單的文字樣式和按鈕樣式的切換了。 接下來我們就要進行關鍵的 shape 渲染了:

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec)
    // 初始化normal狀態
    with(normalGradientDrawable) {
        // 漸變色
        if (mStartColor != Color.parseColor("#FFFFFF") && mEndColor != Color.parseColor("#FFFFFF")) {
            colors = intArrayOf(mStartColor, mEndColor)
            when (mOrientation) {
                0 -> orientation = GradientDrawable.Orientation.TOP_BOTTOM
                1 -> orientation = GradientDrawable.Orientation.LEFT_RIGHT
            }
        }
        // 填充色
        else {
            setColor(mFillColor)
        }
        when (mShapeMode) {
            0 -> shape = GradientDrawable.RECTANGLE
            1 -> shape = GradientDrawable.OVAL
            2 -> shape = GradientDrawable.LINE
            3 -> shape = GradientDrawable.RING
        }
        cornerRadius = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_PX, mCornerRadius.toFloat(), resources.displayMetrics)
        // 預設的透明邊框不繪製,否則會導致沒有陰影
        if (mStrokeColor != Color.parseColor("#00000000")) {
            setStroke(mStrokeWidth, mStrokeColor)
        }
    }

    // 是否開啟點選動效
    background = if (mActiveEnable) {
        // 5.0以上水波紋效果
        if (Build.VERSION.SDK_INT > Build.VERSION_CODES.LOLLIPOP) {
            RippleDrawable(ColorStateList.valueOf(mPressedColor), normalGradientDrawable, null)
        }
        // 5.0以下變色效果
        else {
            // 初始化pressed狀態
            with(pressedGradientDrawable) {
                setColor(mPressedColor)
                when (mShapeMode) {
                    0 -> shape = GradientDrawable.RECTANGLE
                    1 -> shape = GradientDrawable.OVAL
                    2 -> shape = GradientDrawable.LINE
                    3 -> shape = GradientDrawable.RING
                }
                cornerRadius = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_PX, mCornerRadius.toFloat(), resources.displayMetrics)
                setStroke(mStrokeWidth, mStrokeColor)
            }

            // 注意此處的add順序,normal必須在最後一個,否則其他狀態無效
            // 設定pressed狀態
            stateListDrawable.apply {
                addState(intArrayOf(android.R.attr.state_pressed), pressedGradientDrawable)
                // 設定normal狀態
                addState(intArrayOf(), normalGradientDrawable)
            }
        }
    } else {
        normalGradientDrawable
    }
}
複製程式碼

這裡的程式碼有點長,彆著急,我們來慢慢分析一下:

  • 首先是選擇在 onMeasure 方法中做shape渲染
  • 其次對 normarlGradientDrawable 設定當前是漸變色渲染還是填充色渲染,漸變色渲染還需要單獨控制渲染的方向
  • 然後對 normarlGradientDrawable 設定 shape 模式、圓角以及描邊
  • 最後對CommonShapeButton設定background。如果沒有開啟點選特效,則直接返回normarlGradientDrawable。如果開啟了點選特效,那麼 5.0 以上啟用水波紋效果,5.0 以下啟用變色效果。在變色效果的設定中同樣初始化了 pressedGradientDrawable 的 shape 屬性,並且依次新增進了 stateListDrawable 用作背景顯示

到這裡就可以實現了用自定義屬性控制shape渲染顯示 CommonShapeButton 的背景了,這裡貼上全部的屬性:

<declare-styleable name="CommonShapeButton">
    <attr name="csb_shapeMode" format="enum">
        <enum name="rectangle" value="0" />
        <enum name="oval" value="1" />
        <enum name="line" value="2" />
        <enum name="ring" value="3" />
    </attr>
    <attr name="csb_fillColor" format="color" />
    <attr name="csb_pressedColor" format="color" />
    <attr name="csb_strokeColor" format="color" />
    <attr name="csb_strokeWidth" format="dimension" />
    <attr name="csb_cornerRadius" format="dimension" />
    <attr name="csb_activeEnable" format="boolean" />
    <attr name="csb_drawablePosition" format="enum">
        <enum name="left" value="0" />
        <enum name="top" value="1" />
        <enum name="right" value="2" />
        <enum name="bottom" value="3" />
    </attr>
    <attr name="csb_startColor" format="color" />
    <attr name="csb_endColor" format="color" />
    <attr name="csb_orientation" format="enum">
        <enum name="TOP_BOTTOM" value="0" />
        <enum name="LEFT_RIGHT" value="1" />
    </attr>
</declare-styleable>
複製程式碼

接下來我們還需要進行最後的工作,解決在一個 button 中新增 drawable 不居中顯示的問題

override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
    super.onLayout(changed, left, top, right, bottom)
    // 如果xml中配置了drawable則設定padding讓文字移動到邊緣與drawable靠在一起
    // button中配置的drawable預設貼著邊緣
    if (mDrawablePosition > -1) {
        compoundDrawables?.let {
            val drawable: Drawable? = compoundDrawables[mDrawablePosition]
            drawable?.let {
                // 圖片間距
                val drawablePadding = compoundDrawablePadding
                when (mDrawablePosition) {
                // 左右drawable
                    0, 2 -> {
                        // 圖片寬度
                        val drawableWidth = it.intrinsicWidth
                        // 獲取文字寬度
                        val textWidth = paint.measureText(text.toString())
                        // 內容總寬度
                        contentWidth = textWidth + drawableWidth + drawablePadding
                        val rightPadding = (width - contentWidth).toInt()
                        // 圖片和文字全部靠在左側
                        setPadding(0, 0, rightPadding, 0)
                    }
                // 上下drawable
                    1, 3 -> {
                        // 圖片高度
                        val drawableHeight = it.intrinsicHeight
                        // 獲取文字高度
                        val fm = paint.fontMetrics
                        // 單行高度
                        val singeLineHeight = Math.ceil(fm.descent.toDouble() - fm.ascent.toDouble()).toFloat()
                        // 總的行間距
                        val totalLineSpaceHeight = (lineCount - 1) * lineSpacingExtra
                        val textHeight = singeLineHeight * lineCount + totalLineSpaceHeight
                        // 內容總高度
                        contentHeight = textHeight + drawableHeight + drawablePadding
                        // 圖片和文字全部靠在上側
                        val bottomPadding = (height - contentHeight).toInt()
                        setPadding(0, 0, 0, bottomPadding)
                    }
                }
            }

        }
    }
    // 內容居中
    gravity = Gravity.CENTER
    // 可點選
    isClickable = true
}
複製程式碼

我們繼續來分析這裡的程式碼:

  • 首先渲染的效率,我們選擇在 onLayout 方法中計算一些數值
  • 其次由於我們是支援上下左右四個方向的 drawable,所以需要在 xml 中指定屬性 drawablePosition
  • 然後判斷是否設定了 drawable 並且 drawable 獲取不為空
  • 然後判斷 drawable 左右方位,則計算圖片的寬度和文字的寬度,然後根據內容的總寬度把 button 的內容全部貼左邊緣顯示
  • 最後判斷 drawable 在上下方位,則計算圖片的高度和文字的高度,然後根據內容的總高度把 button 的內容全部貼上邊緣顯示

到這裡就做好了讓 drawable 居中顯示的準備工作,我們繼續往下走:

override fun onDraw(canvas: Canvas) {
    // 讓圖片和文字居中
    when {
        contentWidth > 0 && (mDrawablePosition == 0 || mDrawablePosition == 2) -> canvas.translate((width - contentWidth) / 2, 0f)
        contentHeight > 0 && (mDrawablePosition == 1 || mDrawablePosition == 3) -> canvas.translate(0f, (height - contentHeight) / 2)
    }
    super.onDraw(canvas)
}
複製程式碼

接下來我們就是在 onDraw 方法中,利用在 onLayout 方法中計算的數值,平移 button 的內容,從而實現讓 drawable 和文字一起居中顯示。

到這裡我們就完成了 CommonShapeButton 的全部設計和實現,以下是效果圖:

show.gif
最後再附上:github地址傳送門 喜歡就 star 一下唄。

相關文章