作者:咕咚移動技術團隊-Blue
在 Android 開發中,使用 shape 标簽可以很友善的幫我們建構資源檔案,跟傳統的 png 圖檔相比:
shape 标簽可以幫助我們有效減小 apk 安裝包大小。
在不同手機的适配上面,shape 标簽也表現的更加優秀。
關于 shape 标簽如何使用,在網上一搜一大把,筆者就不在這裡贅述了,今天我們要讨論的是 shape 标簽泛濫成災以後帶來的後果。這裡先給大家看一個維護超過了 5 年的項目的 drawable 目錄
image.png
請注意右側标紅的滾動條,有沒有感覺很酸爽,在這個目錄下的檔案現在已經超過了 500 個,并且還在不停的增加。我們分析這個目錄下的 xml 構成,發現主要由兩種類型構成:selector 和 shape。selector 這裡略過不提,重點關注 shape,發現 shape 檔案已經超過了 200 個并且還在不停的增加。我們再帶着好奇的心态随便點開幾個 shape 看一看
android:startColor="#0f000000"
android:endColor="#00000000"
android:angle="270"
/>
android:width="1px"
android:color="#dad9de" />
android:radius="10dp" />
真的是不看不知道,一看吓一跳。原來我們項目中大量存在的 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,在具體業務中會影響到我們的按鈕顯示,是以在自定義按鈕樣式中重置了這三個屬性:
0dp
0dp
0dp
有了自定義按鈕樣式,那麼想要 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 的背景了,這裡貼上全部的屬性:
接下來我們還需要進行最後的工作,解決在一個 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 一下呗。