作者:咕咚移動(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 目錄
請(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)合冀,以下是效果圖:
最后再附上:github地址傳送門 喜歡就 star 一下唄。