Android自定義控件 | 小紅點(diǎn)的三種實(shí)現(xiàn)(上)

小紅點(diǎn)用于通知未讀消息,在應(yīng)用中到處可見赴涵。本文將介紹三種實(shí)現(xiàn)方案。分別是:多控件方案订讼、單控件繪制方案、容器控件繪制方案扇苞。不知道你會更偏向哪種方案欺殿?

Demo 使用 Kotlin 編寫,Kotlin系列教程可以點(diǎn)擊這里

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

  1. Android自定義控件 | View繪制原理(畫多大脖苏?)
  2. Android自定義控件 | View繪制原理(畫在哪?)
  3. Android自定義控件 | View繪制原理(畫什么定踱?)
  4. Android自定義控件 | 源碼里有寶藏之自動換行控件
  5. Android自定義控件 | 小紅點(diǎn)的三種實(shí)現(xiàn)(上)
  6. Android自定義控件 | 小紅點(diǎn)的三種實(shí)現(xiàn)(下)
  7. Android自定義控件 | 小紅點(diǎn)的三種實(shí)現(xiàn)(終結(jié))

多控件方案

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

image

布局文件如下:

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

若要顯示未讀消息數(shù)亦歉,可以將View換成TextView

這個方案最大的優(yōu)點(diǎn)是簡單直觀畅哑,如果項(xiàng)目趕肴楷,沒有太多時間深思,用這交差也不錯荠呐。

但它的缺點(diǎn)是增加了控件的數(shù)量赛蔫,如果一個頁面中有3個小紅點(diǎn),就增加3個控件泥张。

有什么辦法可以兩個控件合成一個控件呵恢?

單控件繪制方案

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

繪制分為兩步:

  1. 繪制紅色背景
  2. 繪制消息數(shù)

繪制背景

Canvas有現(xiàn)成的 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);
    }
}

只需計(jì)算出圓心坐標(biāo)和半徑渗钉,然后在onDraw()中調(diào)用該 API 即可繪制。

背景的圓心應(yīng)該是消息數(shù)的中心點(diǎn)钞钙,背景的半徑依賴于消息數(shù)的長短晌姚,比如,9 條未讀消息就比 999 條的背景要小一圈歇竟。

繪制消息數(shù)

先繪制背景挥唠,再繪制消息數(shù),是為了不讓其被背景擋住焕议。

Canvas有現(xiàn)成的 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);
    }
}

其中第三個參數(shù)y是指文字基線的縱坐標(biāo)宝磨,如下圖所示:

image

畫文字的關(guān)鍵是求出基線在父控件中的縱坐標(biāo)弧关,當(dāng)前 case 的示意圖如下:

image

圓圈代表小紅點(diǎn)的背景,紫線是圓圈的直徑唤锉,也是文字的中軸線世囊。小紅點(diǎn)繪制在控件的右上角,圓圈的上邊和右邊分別貼住控件的上邊和右邊窿祥,所以圓圈頂部切線的縱坐標(biāo)為 0株憾。問題變成已知半徑raduistop晒衩,bottom嗤瞎,求 baseLine 縱坐標(biāo)?(top是負(fù)值听系,bottom為正值)

分解一下計(jì)算步驟:

  • raduis:紫線的縱坐標(biāo)
  • (bottom - top) / 2:文字區(qū)域總高度的一半
  • radius + (bottom - top) / 2:文字底部的縱坐標(biāo)

文字底部的縱坐標(biāo)減掉 bottom 的值就是基線的縱坐標(biāo):

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

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

//'自定義TextView'
open class TagTextView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : AppCompatTextView(context, attrs, defStyleAttr) {
    //'消息數(shù)字體大小'
    var tagTextSize: Float = 0F
        set(value) {
            field = value
            textPaint.textSize = value
        }
    //'消息數(shù)字體顏色'
    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
        }
    //'消息數(shù)字體'
    var tagTextTypeFace: Typeface? = null

    //'消息數(shù)'
    var tagText: String? = null
    //'背景和消息數(shù)的間距'
    var tagTextPaddingTop: Float = 5f
    var tagTextPaddingBottom: Float = 5f
    var tagTextPaddingStart: Float = 5f
    var tagTextPaddingEnd: Float = 5f
    
    //'消息數(shù)字體區(qū)域'
    private var textRect: Rect = Rect()
    //'消息數(shù)畫筆'
    private var textPaint: Paint = Paint()
    //'背景畫筆'
    private var bgPaint: Paint = Paint()

    init {
        //'構(gòu)建消息數(shù)畫筆'
        textPaint.apply {
            color = tagTextColor
            textSize = tagTextSize
            isAntiAlias = true
            textAlign = Paint.Align.CENTER
            style = Paint.Style.FILL
            tagTextTypeFace?.let { typeface = tagTextTypeFace }
        }
        //'構(gòu)建背景畫筆'
        bgPaint.apply {
            isAntiAlias = true
            style = Paint.Style.FILL
            color = tagBgColor
        }
    }

    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)
        //'只有當(dāng)消息數(shù)不為空時才繪制小紅點(diǎn)'
        tagText?.takeIf { it.isNotEmpty() }?.let { text ->
            textPaint.apply {
                //'1.獲取消息數(shù)區(qū)域大小'
                getTextBounds(text, 0, text.length, textRect)
                fontMetricsInt.let {
                    //'背景寬=消息數(shù)區(qū)域?qū)?邊距'
                    val bgWidth = (textRect.right - textRect.left) + tagTextPaddingStart + tagTextPaddingEnd
                    //'背景高=消息數(shù)區(qū)域高+邊距'
                    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 中引用并設(shè)置參數(shù):
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
    }
}

把小紅點(diǎn)的顯示細(xì)節(jié)隱藏在一個自定義View中,這樣布局文件和業(yè)務(wù)層代碼會更加簡潔清晰靠胜。

但這個方案也有以下缺點(diǎn):

  1. 控件類型綁定:若當(dāng)前界面分別有一個TextView掉瞳、ImageViewButton需要顯示小紅點(diǎn),那就需要分別構(gòu)建三種類型的自定義View浪漠。
  2. 控件需留 padding:小紅點(diǎn)是控件的一部分陕习,為了不讓小紅點(diǎn)與控件本體內(nèi)容重疊,控件需給小紅點(diǎn)留有 padding址愿,即控件占用空間會變大衡查,在布局文件中可能引起連鎖反應(yīng),使得其他控件位置也需要跟著微調(diào)必盖。

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

容器控件繪制方案

第三種方案較前兩種略復(fù)雜拌牲,限于篇幅就留到下一篇接著講。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末歌粥,一起剝皮案震驚了整個濱河市塌忽,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌失驶,老刑警劉巖土居,帶你破解...
    沈念sama閱讀 207,113評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異嬉探,居然都是意外死亡擦耀,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,644評論 2 381
  • 文/潘曉璐 我一進(jìn)店門涩堤,熙熙樓的掌柜王于貴愁眉苦臉地迎上來眷蜓,“玉大人,你說我怎么就攤上這事胎围∮跸担” “怎么了德召?”我有些...
    開封第一講書人閱讀 153,340評論 0 344
  • 文/不壞的土叔 我叫張陵,是天一觀的道長汽纤。 經(jīng)常有香客問我上岗,道長,這世上最難降的妖魔是什么蕴坪? 我笑而不...
    開封第一講書人閱讀 55,449評論 1 279
  • 正文 為了忘掉前任肴掷,我火速辦了婚禮,結(jié)果婚禮上背传,老公的妹妹穿的比我還像新娘呆瞻。我一直安慰自己,他們只是感情好续室,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,445評論 5 374
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著谒养,像睡著了一般挺狰。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上买窟,一...
    開封第一講書人閱讀 49,166評論 1 284
  • 那天丰泊,我揣著相機(jī)與錄音,去河邊找鬼始绍。 笑死瞳购,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的亏推。 我是一名探鬼主播学赛,決...
    沈念sama閱讀 38,442評論 3 401
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼吞杭!你這毒婦竟也來了盏浇?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 37,105評論 0 261
  • 序言:老撾萬榮一對情侶失蹤芽狗,失蹤者是張志新(化名)和其女友劉穎绢掰,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體童擎,經(jīng)...
    沈念sama閱讀 43,601評論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡滴劲,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,066評論 2 325
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了顾复。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片班挖。...
    茶點(diǎn)故事閱讀 38,161評論 1 334
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖芯砸,靈堂內(nèi)的尸體忽然破棺而出聪姿,到底是詐尸還是另有隱情碴萧,我是刑警寧澤,帶...
    沈念sama閱讀 33,792評論 4 323
  • 正文 年R本政府宣布末购,位于F島的核電站破喻,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏盟榴。R本人自食惡果不足惜曹质,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,351評論 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望擎场。 院中可真熱鬧羽德,春花似錦、人聲如沸迅办。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,352評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽站欺。三九已至姨夹,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間矾策,已是汗流浹背磷账。 一陣腳步聲響...
    開封第一講書人閱讀 31,584評論 1 261
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留贾虽,地道東北人逃糟。 一個月前我還...
    沈念sama閱讀 45,618評論 2 355
  • 正文 我出身青樓,卻偏偏與公主長得像蓬豁,于是被迫代替她去往敵國和親绰咽。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,916評論 2 344

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