Android自定義控件|小紅點的三種實現(上)

NO IMAGE

小紅點用於通知未讀消息,在應用中到處可見。本文將介紹三種實現方案。分別是:多控件方案、單控件繪製方案、容器控件繪製方案。不知道你會更偏向哪種方案?

Demo 使用 Kotlin 編寫,Kotlin系列教程可以點擊這裡

這是自定義控件系列教程的第五篇,系列文章目錄如下:

  1. Android自定義控件 | View繪製原理(畫多大?)
  2. Android自定義控件 | View繪製原理(畫在哪?)
  3. Android自定義控件 | View繪製原理(畫什麼?)
  4. Android自定義控件 | 源碼裡有寶藏之自動換行控件
  5. Android自定義控件 | 小紅點的三種實現(上)

多控件方案

多控件最容易想到的方案:TextView作為主體控件,View作為附屬小紅點控件相互疊加。效果如下:

Android自定義控件|小紅點的三種實現(上)

佈局文件如下:

<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<TextView
android:id="@+id/tvMsg"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="消息"
android:textSize="20sp"/>
<View
android:layout_width="6dp"
android:layout_height="6dp"
android:background="@drawable/red_shape"
app:layout_constraintEnd_toEndOf="@id/tvMsg"
app:layout_constraintTop_toTopOf="@id/tvMsg" />
</androidx.constraintlayout.widget.ConstraintLayout>

其中red_shape是一個紅色圓形shape資源文件,代碼如下:

<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<size android:width="20dp"
android:height="20dp"/>
<solid android:color="#ff0000"/>
</shape>

若要顯示未讀消息數,可以將View換成TextView

這個方案最大的優點是簡單直觀,如果項目趕,沒有太多時間深思,用這交差也不錯。

但它的缺點是增加了控件的數量,如果一個頁面中有3個小紅點,就增加3個控件。

有什麼辦法可以兩個控件合成一個控件?

單控件繪製方案

是不是可以自定義一個TextView,在右上角繪製一個紅圈。

繪製分為兩步:

  1. 繪製紅色背景
  2. 繪製消息數

繪製背景

Canvas有現成的 API 繪製圓圈:

public class Canvas extends BaseCanvas {
/**
* Draw the specified circle using the specified paint. If radius is <= 0, then nothing will be
* drawn. The circle will be filled or framed based on the Style in the paint.
*
* @param cx The x-coordinate of the center of the cirle to be drawn
* @param cy The y-coordinate of the center of the cirle to be drawn
* @param radius The radius of the cirle to be drawn
* @param paint The paint used to draw the circle
*/
public void drawCircle(float cx, float cy, float radius, @NonNull Paint paint) {
super.drawCircle(cx, cy, radius, paint);
}
}

只需計算出圓心座標和半徑,然後在onDraw()中調用該 API 即可繪製。

背景的圓心應該是消息數的中心點,背景的半徑依賴於消息數的長短,比如,9 條未讀消息就比 999 條的背景要小一圈。

繪製消息數

先繪製背景,再繪製消息數,是為了不讓其被背景擋住。

Canvas有現成的 API 繪製文字:

public class Canvas extends BaseCanvas {
/**
* Draw the text, with origin at (x,y), using the specified paint. The origin is interpreted
* based on the Align setting in the paint.
*
* @param text The text to be drawn
* @param x The x-coordinate of the origin of the text being drawn
* @param y The y-coordinate of the baseline of the text being drawn
* @param paint The paint used for the text (e.g. color, size, style)
*/
public void drawText(@NonNull String text, float x, float y, @NonNull Paint paint) {
super.drawText(text, x, y, paint);
}
}

其中第三個參數y是指文字基線的縱座標,如下圖所示:

Android自定義控件|小紅點的三種實現(上)

畫文字的關鍵是求出基線在父控件中的縱座標,當前 case 的示意圖如下:

Android自定義控件|小紅點的三種實現(上)

圓圈代表小紅點的背景,紫線是圓圈的直徑,也是文字的中軸線。小紅點繪製在控件的右上角,圓圈的上邊和右邊分別貼住控件的上邊和右邊,所以圓圈頂部切線的縱座標為 0。問題變成已知半徑raduistopbottom,求 baseLine 縱座標?(top是負值,bottom為正值)

分解一下計算步驟:

  • raduis:紫線的縱座標
  • (bottom – top) / 2:文字區域總高度的一半
  • radius + (bottom – top) / 2:文字底部的縱座標

文字底部的縱座標減掉 bottom 的值就是基線的縱座標:

baseline = radius + (bottom – top) / 2 – bottom

然後只要在自定義控件的onDraw()中先繪製背景再繪製消息數即可,自定義控件完整代碼如下:

//'自定義TextView'
open class TagTextView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : AppCompatTextView(context, attrs, defStyleAttr) {
//'消息數字體大小'
var tagTextSize: Float = 0F
set(value) {
field = value
textPaint.textSize = value
}
//'消息數字體顏色'
var tagTextColor: Int = Color.parseColor("#FFFFFF")
set(value) {
field = value
textPaint.color = value
}
//'背景色'
var tagBgColor: Int = Color.parseColor("#FFFF5183")
set(value) {
field = value
bgPaint.color = value
}
//'消息數字體'
var tagTextTypeFace: Typeface? = null
//'消息數'
var tagText: String? = null
//'背景和消息數的間距'
var tagTextPaddingTop: Float = 5f
var tagTextPaddingBottom: Float = 5f
var tagTextPaddingStart: Float = 5f
var tagTextPaddingEnd: Float = 5f
//'消息數字體區域'
private var textRect: Rect = Rect()
//'消息數畫筆'
private var textPaint: Paint = Paint()
//'背景畫筆'
private var bgPaint: Paint = Paint()
init {
//'構建消息數畫筆'
textPaint.apply {
color = tagTextColor
textSize = tagTextSize
isAntiAlias = true
textAlign = Paint.Align.CENTER
style = Paint.Style.FILL
tagTextTypeFace?.let { typeface = tagTextTypeFace }
}
//'構建背景畫筆'
bgPaint.apply {
isAntiAlias = true
style = Paint.Style.FILL
color = tagBgColor
}
}
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
//'只有當消息數不為空時才繪製小紅點'
tagText?.takeIf { it.isNotEmpty() }?.let { text ->
textPaint.apply {
//'1.獲取消息數區域大小'
getTextBounds(text, 0, text.length, textRect)
fontMetricsInt.let {
//'背景寬=消息數區域寬+邊距'
val bgWidth = (textRect.right - textRect.left) + tagTextPaddingStart + tagTextPaddingEnd
//'背景高=消息數區域高+邊距'
val bgHeight = tagTextPaddingBottom + tagTextPaddingTop + it.bottom - it.top
//'取寬高中的較大值作為半徑'
val radius = if (bgWidth > bgHeight) bgWidth / 2 else bgHeight / 2
val centerX = width - radius
val centerY = radius
//'2.繪製背景'
canvas?.drawCircle(centerX, centerY, radius, bgPaint)
//'3.繪製基線'
val baseline = radius + (it.bottom - it.top) / 2 - it.bottom
canvas?.drawText(text, width - radius, baseline, textPaint)
}
}
}
}
}

然後就能像這樣使用自定義控件:

  1. 在佈局文件中聲明
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<test.taylor.com.taylorcode.ui.custom_view.tag_view.TagTextView
android:id="@+id/ttv"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="8dp"
android:text="bug"/>
</androidx.constraintlayout.widget.ConstraintLayout>
  1. 在 Activity 中引用並設置參數:
class TagTextViewActivity:AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.tag_textview_activity)
ttv.tagText = "+99"
ttv.tagTextSize = dip(8F)
ttv.tagTextColor = Color.YELLOW
}
}

把小紅點的顯示細節隱藏在一個自定義View中,這樣佈局文件和業務層代碼會更加簡潔清晰。

但這個方案也有以下缺點:

  1. 控件類型綁定:若當前界面分別有一個TextViewImageViewButton需要顯示小紅點,那就需要分別構建三種類型的自定義View。
  2. 控件需留 padding:小紅點是控件的一部分,為了不讓小紅點與控件本體內容重疊,控件需給小紅點留有 padding,即控件佔用空間會變大,在佈局文件中可能引起連鎖反應,使得其他控件位置也需要跟著微調。

於是乎就有了第三種方案~~

容器控件繪製方案

第三種方案較前兩種略複雜,限於篇幅就留到下一篇接著講。

相關文章

刪庫了,我們一定要跑路嗎?

Flutter混合開發實戰問題記錄(五)1.9.1hotfix打包aar差異

Flutter完整開發實戰詳解(二十、AndroidPlatformView和鍵盤問題)

從同一功能的八種實現,談談react中的邏輯複用進化過程