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

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

Demo 使用 Kotlin 編寫,Kotlin系列教程能夠點擊這裏canvas

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

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

多控件方案

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

佈局文件以下:

<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資源文件,代碼以下:ide

<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佈局

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

但它的缺點是增長了控件的數量,若是一個頁面中有3個小紅點,就增長3個控件。字體

有什麼辦法能夠兩個控件合成一個控件?ui

單控件繪製方案

是否是能夠自定義一個TextView,在右上角繪製一個紅圈。spa

繪製分爲兩步:

  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是指文字基線的縱座標,以下圖所示:

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

圓圈表明小紅點的背景,紫線是圓圈的直徑,也是文字的中軸線。小紅點繪製在控件的右上角,圓圈的上邊和右邊分別貼住控件的上邊和右邊,因此圓圈頂部切線的縱座標爲 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,即控件佔用空間會變大,在佈局文件中可能引發連鎖反應,使得其餘控件位置也須要跟着微調。

因而乎就有了第三種方案~~

容器控件繪製方案

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

相關文章
相關標籤/搜索