Android 項(xiàng)目中 shape 標(biāo)簽的整理和思考

作者:咕咚移動(dòng)技術(shù)團(tuán)隊(duì)-Blue

在 Android 開發(fā)中系任,使用 shape 標(biāo)簽可以很方便的幫我們構(gòu)建資源文件,跟傳統(tǒng)的 png 圖片相比:

  • shape 標(biāo)簽可以幫助我們有效減小 apk 安裝包大小嫌变。
  • 在不同手機(jī)的適配上面,shape 標(biāo)簽也表現(xiàn)的更加優(yōu)秀躬它。

關(guān)于 shape 標(biāo)簽如何使用腾啥,在網(wǎng)上一搜一大把,筆者就不在這里贅述了冯吓,今天我們要討論的是 shape 標(biāo)簽泛濫成災(zāi)以后帶來的后果倘待。這里先給大家看一個(gè)維護(hù)超過了 5 年的項(xiàng)目的 drawable 目錄


image.png

請(qǐng)注意右側(cè)標(biāo)紅的滾動(dòng)條,有沒有感覺很酸爽组贺,在這個(gè)目錄下的文件現(xiàn)在已經(jīng)超過了 500 個(gè)凸舵,并且還在不停的增加。我們分析這個(gè)目錄下的 xml 構(gòu)成失尖,發(fā)現(xiàn)主要由兩種類型構(gòu)成:selector 和 shape啊奄。selector 這里略過不提,重點(diǎn)關(guān)注 shape掀潮,發(fā)現(xiàn) shape 文件已經(jīng)超過了 200 個(gè)并且還在不停的增加菇夸。我們?cè)賻е闷娴男膽B(tài)隨便點(diǎn)開幾個(gè) 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>

真的是不看不知道,一看嚇一跳仪吧。原來我們項(xiàng)目中大量存在的 shape 文件其實(shí)都是大同小異的庄新,涉及到最常見的 shape 變化:圓角,描邊,填充以及漸變择诈。
進(jìn)一步分析械蹋,我們又發(fā)現(xiàn):

  • 有些時(shí)候填充顏色是相同的,只不過圓角半徑不同吭从,我們就得新增一個(gè) shape 文件朝蜘。
  • 有些時(shí)候圓角半徑是相同的,只不過填充顏色不同涩金,我們又得新增一個(gè) shape 文件谱醇。
  • 有些時(shí)候兩個(gè)負(fù)責(zé)不同業(yè)務(wù)模塊的同事资盅,各自新增一個(gè)同樣樣式的 shape 文件廷蓉。

等等一些情況虫溜,讓我們陷入了 shape 文件的無限新增與維護(hù)中拒担。我們不禁要思考脖卖,有沒有辦法可以把這些 shape 統(tǒng)一起來管理呢肋演?xml 書寫出來的代碼最終不都是會(huì)對(duì)應(yīng)一個(gè)內(nèi)存中的對(duì)象嗎鹦赎?我們能不能從管理 shape 文件過度到管理一個(gè)對(duì)象呢甚脉?

Talk is cheap. Show me the code

第一步将鸵,我們需要確定 shape 標(biāo)簽對(duì)應(yīng)的類到底是哪一個(gè)勉盅?第一反應(yīng)就是 ShapeDrawable,顧名思義嘛顶掉。然后殘酷的事實(shí)告訴我們其實(shí)是 GradientDrawable 這兄弟草娜。瀏覽 GradientDrawable 類的方法結(jié)構(gòu),從中我們也找到了setColor()痒筒、setCornerRadius()宰闰、setStroke() 等目標(biāo)方法。好吧簿透,不管怎樣移袍,先找到正主了。

第二步老充,繼續(xù)思考如何來設(shè)計(jì)這個(gè)通用控件葡盗,主要從以下幾個(gè)方面進(jìn)行了考慮:

  • shape 的應(yīng)用場景有可能是文字標(biāo)簽,也有可能是響應(yīng)按鈕蚂维,所以需要文本和按鈕兩種樣式戳粒,兩者的主要區(qū)別在于按鈕樣式在普通狀態(tài)下和按壓狀態(tài)下都具有陰影。
  • 為了提升用戶體驗(yàn)虫啥,設(shè)計(jì)了通用控件的按壓動(dòng)效蔚约。針對(duì) 5.0 以上的用戶開啟按壓水波紋效果,針對(duì) 5.0 以下的用戶開啟按壓變色效果涂籽。
    結(jié)合以上兩點(diǎn)苹祟,通用控件的實(shí)現(xiàn)考慮直接繼承 AppCompatButton 進(jìn)行擴(kuò)展。
  • 具體的業(yè)務(wù)場景中,通用控件的使用還有可能伴隨著 drawable树枫,并且要求 drawable 和文字一起居中顯示直焙。其實(shí)這個(gè)問題本來是不需要單獨(dú)考慮的,但是 Android 有個(gè)坑砂轻,在一個(gè)按鈕控件中設(shè)置 drawable 以后奔誓,默認(rèn)是貼著控件邊緣顯示的,所以這個(gè)坑需要單獨(dú)填搔涝。
  • 自定義控件屬性支持 shape 模式厨喂、填充顏色、按壓顏色庄呈、描邊顏色蜕煌、描邊寬度、圓角半徑诬留、按壓動(dòng)效是否開啟斜纪、漸變開始顏色、漸變結(jié)束顏色文兑、漸變方向盒刚、drawable 方位。

第三步绿贞,思路已經(jīng)梳理清楚了伪冰,那就開擼。

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

這里實(shí)現(xiàn)了繼承 AppCompatButton 進(jìn)行擴(kuò)展樟蠕,默認(rèn)樣式 defStyleAttr 傳遞的是 0,那么 CommonShapeButton 的默認(rèn)表現(xiàn)形式就是文本樣式靠柑。

如果想要采用按鈕樣式寨辩,則需要先自定義一個(gè)按鈕樣式,原因是系統(tǒng)按鈕的樣式自帶了 minWidth歼冰、minHeight 以及 padding靡狞,在具體業(yè)務(wù)中會(huì)影響到我們的按鈕顯示,所以在自定義按鈕樣式中重置了這三個(gè)屬性:

<!-- 自定義按鈕樣式 -->
<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"/>

到這里就可以實(shí)現(xiàn)簡單的文本樣式和按鈕樣式的切換了。
接下來我們就要進(jìn)行關(guān)鍵的 shape 渲染了:

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec)
    // 初始化normal狀態(tài)
    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)
        // 默認(rèn)的透明邊框不繪制,否則會(huì)導(dǎo)致沒有陰影
        if (mStrokeColor != Color.parseColor("#00000000")) {
            setStroke(mStrokeWidth, mStrokeColor)
        }
    }

    // 是否開啟點(diǎn)擊動(dòng)效
    background = if (mActiveEnable) {
        // 5.0以上水波紋效果
        if (Build.VERSION.SDK_INT > Build.VERSION_CODES.LOLLIPOP) {
            RippleDrawable(ColorStateList.valueOf(mPressedColor), normalGradientDrawable, null)
        }
        // 5.0以下變色效果
        else {
            // 初始化pressed狀態(tài)
            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必須在最后一個(gè)梢杭,否則其他狀態(tài)無效
            // 設(shè)置pressed狀態(tài)
            stateListDrawable.apply {
                addState(intArrayOf(android.R.attr.state_pressed), pressedGradientDrawable)
                // 設(shè)置normal狀態(tài)
                addState(intArrayOf(), normalGradientDrawable)
            }
        }
    } else {
        normalGradientDrawable
    }
}

這里的代碼有點(diǎn)長,別著急秸滴,我們來慢慢分析一下:

  • 首先是選擇在 onMeasure 方法中做shape渲染
  • 其次對(duì) normarlGradientDrawable 設(shè)置當(dāng)前是漸變色渲染還是填充色渲染武契,漸變色渲染還需要單獨(dú)控制渲染的方向
  • 然后對(duì) normarlGradientDrawable 設(shè)置 shape 模式、圓角以及描邊
  • 最后對(duì)CommonShapeButton設(shè)置background。如果沒有開啟點(diǎn)擊特效咒唆,則直接返回normarlGradientDrawable届垫。如果開啟了點(diǎn)擊特效,那么 5.0 以上啟用水波紋效果全释,5.0 以下啟用變色效果装处。在變色效果的設(shè)置中同樣初始化了 pressedGradientDrawable 的 shape 屬性,并且依次添加進(jìn)了 stateListDrawable 用作背景顯示

到這里就可以實(shí)現(xiàn)了用自定義屬性控制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>

接下來我們還需要進(jìn)行最后的工作妄迁,解決在一個(gè) button 中添加 drawable 不居中顯示的問題

override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
    super.onLayout(changed, left, top, right, bottom)
    // 如果xml中配置了drawable則設(shè)置padding讓文字移動(dòng)到邊緣與drawable靠在一起
    // button中配置的drawable默認(rèn)貼著邊緣
    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())
                        // 內(nèi)容總寬度
                        contentWidth = textWidth + drawableWidth + drawablePadding
                        val rightPadding = (width - contentWidth).toInt()
                        // 圖片和文字全部靠在左側(cè)
                        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
                        // 內(nèi)容總高度
                        contentHeight = textHeight + drawableHeight + drawablePadding
                        // 圖片和文字全部靠在上側(cè)
                        val bottomPadding = (height - contentHeight).toInt()
                        setPadding(0, 0, 0, bottomPadding)
                    }
                }
            }

        }
    }
    // 內(nèi)容居中
    gravity = Gravity.CENTER
    // 可點(diǎn)擊
    isClickable = true
}

我們繼續(xù)來分析這里的代碼:

  • 首先渲染的效率,我們選擇在 onLayout 方法中計(jì)算一些數(shù)值
  • 其次由于我們是支持上下左右四個(gè)方向的 drawable糟袁,所以需要在 xml 中指定屬性 drawablePosition
  • 然后判斷是否設(shè)置了 drawable 并且 drawable 獲取不為空
  • 然后判斷 drawable 左右方位判族,則計(jì)算圖片的寬度和文字的寬度,然后根據(jù)內(nèi)容的總寬度把 button 的內(nèi)容全部貼左邊緣顯示
  • 最后判斷 drawable 在上下方位项戴,則計(jì)算圖片的高度和文字的高度形帮,然后根據(jù)內(nèi)容的總高度把 button 的內(nèi)容全部貼上邊緣顯示

到這里就做好了讓 drawable 居中顯示的準(zhǔn)備工作,我們繼續(xù)往下走:

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 方法中計(jì)算的數(shù)值辩撑,平移 button 的內(nèi)容,從而實(shí)現(xiàn)讓 drawable 和文字一起居中顯示仿耽。

到這里我們就完成了 CommonShapeButton 的全部設(shè)計(jì)和實(shí)現(xiàn)合冀,以下是效果圖:

show.gif

最后再附上:github地址傳送門 喜歡就 star 一下唄。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末项贺,一起剝皮案震驚了整個(gè)濱河市君躺,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌开缎,老刑警劉巖棕叫,帶你破解...
    沈念sama閱讀 211,639評(píng)論 6 492
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異奕删,居然都是意外死亡俺泣,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,277評(píng)論 3 385
  • 文/潘曉璐 我一進(jìn)店門完残,熙熙樓的掌柜王于貴愁眉苦臉地迎上來伏钠,“玉大人,你說我怎么就攤上這事谨设∈斓啵” “怎么了?”我有些...
    開封第一講書人閱讀 157,221評(píng)論 0 348
  • 文/不壞的土叔 我叫張陵铝宵,是天一觀的道長打掘。 經(jīng)常有香客問我华畏,道長,這世上最難降的妖魔是什么尊蚁? 我笑而不...
    開封第一講書人閱讀 56,474評(píng)論 1 283
  • 正文 為了忘掉前任亡笑,我火速辦了婚禮,結(jié)果婚禮上横朋,老公的妹妹穿的比我還像新娘仑乌。我一直安慰自己,他們只是感情好琴锭,可當(dāng)我...
    茶點(diǎn)故事閱讀 65,570評(píng)論 6 386
  • 文/花漫 我一把揭開白布晰甚。 她就那樣靜靜地躺著,像睡著了一般决帖。 火紅的嫁衣襯著肌膚如雪厕九。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,816評(píng)論 1 290
  • 那天地回,我揣著相機(jī)與錄音扁远,去河邊找鬼。 笑死刻像,一個(gè)胖子當(dāng)著我的面吹牛畅买,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播细睡,決...
    沈念sama閱讀 38,957評(píng)論 3 408
  • 文/蒼蘭香墨 我猛地睜開眼谷羞,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了溜徙?” 一聲冷哼從身側(cè)響起湃缎,我...
    開封第一講書人閱讀 37,718評(píng)論 0 266
  • 序言:老撾萬榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎蠢壹,沒想到半個(gè)月后雁歌,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 44,176評(píng)論 1 303
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡知残,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,511評(píng)論 2 327
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了比庄。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片求妹。...
    茶點(diǎn)故事閱讀 38,646評(píng)論 1 340
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖佳窑,靈堂內(nèi)的尸體忽然破棺而出制恍,到底是詐尸還是另有隱情,我是刑警寧澤神凑,帶...
    沈念sama閱讀 34,322評(píng)論 4 330
  • 正文 年R本政府宣布净神,位于F島的核電站何吝,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏鹃唯。R本人自食惡果不足惜爱榕,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,934評(píng)論 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望坡慌。 院中可真熱鬧黔酥,春花似錦、人聲如沸洪橘。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,755評(píng)論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽熄求。三九已至渣玲,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間弟晚,已是汗流浹背忘衍。 一陣腳步聲響...
    開封第一講書人閱讀 31,987評(píng)論 1 266
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留指巡,地道東北人淑履。 一個(gè)月前我還...
    沈念sama閱讀 46,358評(píng)論 2 360
  • 正文 我出身青樓,卻偏偏與公主長得像藻雪,于是被迫代替她去往敵國和親秘噪。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 43,514評(píng)論 2 348

推薦閱讀更多精彩內(nèi)容