Android開發&TextView設定精確間隔

安卓中最長使用的控件就是TextView,一般而言,使用時只是簡單的設置文字,大小,顏色,尺寸。稍微複雜一些的,我們使用Span標籤,Drawable***等富文本。可能爲了顯示效果,還會進行paddingmargin調整,以及 跑馬燈 效果的展示。

有時我們可能需要設置文字的行間隔,於是就用了lineSpacingExtra , lineSpacingMultiplier
有時想設置文字拉伸效果,於是會配置textScaleX
或者更進一步,想要修改文本間隔,這時需要設置letterSpacing
。。。

類似的情況還有很多,不過這些不是目前考慮的重點,事實上以上的內容雖然比較繁瑣(單從上萬行的 TextView 源碼便可以看出該控件的功能性),但大多有規律可循,因此並不難使用。

比較麻煩的是,平時進行開發時,我們參照的UI設計圖(一般爲ps原件)一般都是這個樣子 的 :

在這裏插入圖片描述

可以直接測量出兩個TextView之間的間距爲33dp,但在實際開發中,這裏是無法照抄該尺寸的,TextView在繪製文字的時候,會自帶一些padding效果,因此按照33dp顯示出來的效果將會比設計圖上大的多。

通常情況,開發人員在指定間隔時,會採用比該值小一些的,比較規範的間隔,比如常用的一些間隔尺寸:16dp32dp48dp64dp24dp,雖然大體上效果類似,但不免有些‘碓答案’的嫌疑。

如果要明白其中的貓膩,就需要去了解一些文字的顯示方式了

1、TextView中文字繪製的規則?

在自定義View控件時,都會瞭解到Canvas的存在,我們看到的效果都是直接在畫布上draw出來的,文字也不例外。

TextView中會保持一個畫筆:Paint,我們可以在任何地方拿到該對象

TextPaint paint = tv.getPaint();

TextPaintPaint的子類,文本的顯示等效果都是由該畫筆Canvas上勾勒出來的,繪製文字的方法有這些:
在這裏插入圖片描述

無論哪個重載的方法,都需要傳入一個座標值:(x,y)

這個座標是文字繪製的起點,但並不是文字左下角的座標,已TextPaint定義的規則來看的話,大概是這個樣子:

在這裏插入圖片描述

這裏列舉了多種類型的文本信息包括大小寫,上標,表情等;圖中顯示出了五條線,限定了文字繪製的模版,代表的含義可以查看 FontMetrics類 源碼:

// paint爲TextPaint類的實例
val fontMetrics = paint.fontMetrics
public static class FontMetrics {
    /**
     * The maximum distance above the baseline for the tallest glyph in
     * the font at a given text size.
     */
    public float   top;
    /**
     * The recommended distance above the baseline for singled spaced text.
     */
    public float   ascent;
    /**
     * The recommended distance below the baseline for singled spaced text.
     */
    public float   descent;
    /**
     * The maximum distance below the baseline for the lowest glyph in
     * the font at a given text size.
     */
    public float   bottom;
    /**
     * The recommended additional space to add between lines of text.
     */
    public float   leading;
}

爲了直觀的看到效果,我們將TextViewpaddingmargin值設置 爲0dp,設置文字大小爲100sp,然後對比着說明FontMetrics類中各字段的含義:

  1. top:控件的最頂部,取值 -209.5999…
  2. ascent:控件文字可達的最頂部,取值 -185.59999…
  3. leading:即baseline,文字基準線,繪製文字時,y座標爲該值,取值 0
  4. descent:控件文字可達的最底部,取值 48.8
  5. bottom:控件的最底部,取值 55.4

注:需要注意的是 這裏所有的取值,都是在TextView爲默認樣式下獲取的

該TextView的寬高爲:width: 1280 || height: 266

有了上面的具體數據,我們不難發現一些事實:

* top 和 bottom 之間距離,正好等於 TextView 的高度 ;(可能會有數dp的偏差,這個是由於繪製時,小數向上或向下取值導致的)
* ascent 和 descent 之間的距離,正好是文字內容可達的最大高度;(但從上面可以看出,不同的字符佔據的位置是不同的,很多都沒有達到最大高度,這個可以參考最後給出的幾張包含表情符號的圖)
* leading和baseline都是指基準線,也是某些情況下的baseline,我們使用 ConstraintLayout 約束佈局時,其中會有 layout_constraintBaseline_toBaselineOf 屬性,可以指定兩個TextView的基準線對齊

TextView中,還有這樣的一個屬性:

android:includeFontPadding="true"

該屬性爲false時,TextView的高度就變成了 ascent 和 descent 之間的距離

在這裏插入圖片描述

上面的結果正好驗證了我們之前的結論,即:

  • 在includeFontPadding 爲 true時,控件高度爲 top 到 bottom 之間距離
  • 在includeFontPadding 爲 false時,控件高度爲ascent到descent之間距離

當然,這一切的前提是沒有drawable和padding等影響因素的存在。在知道了文字所處的 空間 規則,我們需要怎麼來處理 PS圖 與 實際效果 的差別呢?

2、根據上方控件改變自身margin值

在做嘗試之前,需要先明確一些事實:

我們進行UI佈局時,情況要比簡單的基本佈局複雜的多,因此這裏做一些限制,在一些既定使用的情況下,來完成目標的設定;

這些既定使用的規則爲:

  1. 僅限LinearLayout佈局,且只考慮 VERTICAL 情況下的排版方式
  2. 在LinearLayout佈局中,ActualTextView的includeFontPadding屬性必須爲true,否則具體計算的結果會有偏差
  3. 在LinearLayout佈局中,我們約定 topMargin 屬性優先,bottomMargin將不影響佈局效果
  4. LinearLayout佈局最後一個Child距離底部的距離不做處理
  5. 只能在xml中進行佈局設置

我們姑且按照上面的規則嚴格執行,當然,這些條件相當苛刻,不過這裏只是尋求一種解,並不考慮所有的情況。注:事實上考慮所有的情況將相當複雜,可能邏輯將無法進行下去

好了,在做出瞭如此多限定後,這裏將對TextView進行簡單的改造,首先,我們需要獲取到原始的 topMargin 值:

override fun setLayoutParams(params: ViewGroup.LayoutParams?) {
    super.setLayoutParams(params)

    (layoutParams as ViewGroup.MarginLayoutParams?)?.let {
        originMarginTop = it.topMargin
        originMarginBottom = it.bottomMargin
    }
}

該值在設定後,不要輕易改變,不然可能無法還原回去;
然後我們在 TextView 添加到 父ViewGroup之後,去動態的改變 topMargin,使之滿足我們的需要:

override fun hasFocus(): Boolean {
    if (firstInvokeHasFocus) {
        (parent is LinearLayout && (parent as LinearLayout).orientation == LinearLayout.VERTICAL).let {
            //有父類的情況下,查看當前是否有includePadding
            if (includeFontPadding) {
                //判斷自身的marginTop值是否存在
                if (originMarginTop != 0) {
                    //如果存在,則進行縮減,縮減的尺寸爲: 自身 (ascent - top) + 上層佈局(若爲ActualTextView且includeFontPadding爲true的話)(bottom - descent)
                    var delete = paint.fontMetrics.ascent - paint.fontMetrics.top
                    val position = (parent as ViewGroup).indexOfChild(this@ActualTextView)
                    val before: View? = (parent as ViewGroup).getChildAt(position - 1)
                    if (before is ActualTextView && before.includeFontPadding) {
                        delete += before.paint.fontMetrics.bottom - before.paint.fontMetrics.descent
                    }

                    //爲當前的marginTop賦值
                    (layoutParams as LinearLayout.LayoutParams).topMargin = (originMarginTop - delete).toInt().also { if (it < 0) 0 else it }
                }
            }
        }
        firstInvokeHasFocus = false
    }

    return super.hasFocus()
}

邏輯很簡單,就是將上個兄弟View的底部多餘部分,與當前View的頂部多餘部分給減去。
這裏將有一種意外情況出現:我們需要減去的部分,比設置的margin值還要大
在字體比較大,margin比較小時,這種情況是很容易出現的,不過還好,LinearLayout佈局允許設置負的margin值,這也是上面要做出諸多限制的原因。

爲了顯示效果清晰一些,我們加上一些帶透明度的背景。

在這裏插入圖片描述

六個TextView對應的topmargin分別爲:0dp,20dp、10dp、5dp、1dp、0dp

在經過處理後,margin 值對應的變成了:

09-30 15:23:12.864 3021-3021/com.example.test.tv E/topMargin::::: -12
09-30 15:23:12.866 3021-3021/com.example.test.tv E/topMargin::::: 24
09-30 15:23:12.868 3021-3021/com.example.test.tv E/topMargin::::: 4
09-30 15:23:12.870 3021-3021/com.example.test.tv E/topMargin::::: -5
09-30 15:23:12.872 3021-3021/com.example.test.tv E/topMargin::::: -13
09-30 15:23:12.874 3021-3021/com.example.test.tv E/topMargin::::: -15

去除背景,添加5條line,查看佈局情況:

在這裏插入圖片描述

可以看到倒數第二條綠線比較粗,這是因爲在設置了 top-margin爲0dp的情況下,上個 TextView 的 descent當前 TextView 的 ascent 重合在了一起。

3、結語&代碼

如此一來,就完成了既定規則下,設計圖與效果圖的統一,不過如果需要適應多種情況,則需要真正使用時,進行專門的定製了。這裏貼上之前測試使用的自定義TextView的源碼:

/**
 * function : 包含margin的TextView
 *
 * Created on 2018/9/29  18:35
 * @author mnlin
 */
class ActualTextView : AppCompatTextView {
    /**
     * 距離的原始高度
     */
    private var originMarginTop: Int = 0
    private var originMarginBottom: Int = 0

    /**
     * 第一次調用hasFocus方法
     */
    var firstInvokeHasFocus = true

    constructor(context: Context) : this(context, null)

    constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)

    constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
        initPlugins()
    }

    /**
     * 初始化插件類
     */
    private fun initPlugins() {
        setSingleLine()
        ellipsize = TextUtils.TruncateAt.MARQUEE
        marqueeRepeatLimit = -1
    }

    override fun setLayoutParams(params: ViewGroup.LayoutParams?) {
        super.setLayoutParams(params)

        (layoutParams as ViewGroup.MarginLayoutParams?)?.let {
            originMarginTop = it.topMargin
            originMarginBottom = it.bottomMargin
        }
    }

    override fun hasFocus(): Boolean {
        if (firstInvokeHasFocus) {
            (parent is LinearLayout && (parent as LinearLayout).orientation == LinearLayout.VERTICAL).let {
                //有父類的情況下,查看當前是否有includePadding
                if (includeFontPadding) {
                    //判斷自身的marginTop值是否存在
                    if (originMarginTop != 0 || true) {
                        //如果存在,則進行縮減,縮減的尺寸爲: 自身 (ascent - top) + 上層佈局(若爲ActualTextView且includeFontPadding爲true的話)(bottom - descent)
                        var delete = paint.fontMetrics.ascent - paint.fontMetrics.top
                        val position = (parent as ViewGroup).indexOfChild(this@ActualTextView)
                        val before: View? = (parent as ViewGroup).getChildAt(position - 1)
                        if (before is ActualTextView && before.includeFontPadding) {
                            delete += before.paint.fontMetrics.bottom - before.paint.fontMetrics.descent
                        }

                        //爲當前的marginTop賦值
                        (layoutParams as LinearLayout.LayoutParams).topMargin = (originMarginTop - delete).toInt().also {
                            Log.e("topMargin::::", it.toString())
                            if (it < 0)
                                0
                            else
                                it
                        }
                    }
                }
            }
            firstInvokeHasFocus = false
        }

        return super.hasFocus()
    }

    override fun isFocused(): Boolean {
        return true
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)

        val measuredWidth = measuredWidth

        val paint = paint

        val fontMetrics = paint.fontMetrics

        val top = if (includeFontPadding) fontMetrics.top else fontMetrics.ascent

        paint.color = Color.BLACK
        canvas.drawLine(0f, fontMetrics.leading - top, measuredWidth.toFloat(), fontMetrics.leading - top, paint)

        paint.color = Color.RED
        canvas.drawLine(0f, fontMetrics.top - top, measuredWidth.toFloat(), fontMetrics.top - top, paint)
        canvas.drawLine(0f, fontMetrics.bottom - top, measuredWidth.toFloat(), fontMetrics.bottom - top, paint)

        paint.color = Color.GREEN
        canvas.drawLine(0f, fontMetrics.ascent - top, measuredWidth.toFloat(), fontMetrics.ascent - top, paint)
        canvas.drawLine(0f, fontMetrics.descent - top, measuredWidth.toFloat(), fontMetrics.descent - top, paint)

        paint.color = Color.BLUE
        canvas.drawLine(100f, paint.baselineShift - top, measuredWidth.toFloat(), paint.baselineShift - top, paint)

        Log.e("fontMetrics::::", "top:${fontMetrics.top} ; ascent:${fontMetrics.ascent} ; leading:${fontMetrics.leading} ; descent:${fontMetrics.descent} ; bottom:${fontMetrics.bottom}")
        Log.e("width: || height: ", width.toString() + "  ||  " + height)
    }
}

以及其中使用到的複雜的表情等:

<string name="common_test">亂2JYjy²😃麣👇√∈✔₎</string>

最後使用的佈局文件xml:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="center"
    android:orientation="vertical"
    tools:context=".MainActivity">
    <!--android:background="#00005555"-->
    <com.example.test.tv.ActualTextView
        android:id="@+id/tv_one"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:layout_margin="0dp"
        android:background="#00005555"
        android:includeFontPadding="true"
        android:padding="0dp"
        android:singleLine="true"
        android:text="@string/common_test"
        android:textSize="50sp"/>

    <!--android:background="#00555500"-->
    <com.example.test.tv.ActualTextView
        android:id="@+id/tv_two"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:layout_marginTop="20dp"
        android:background="#00555500"
        android:includeFontPadding="true"
        android:letterSpacing="0"
        android:padding="0dp"
        android:singleLine="true"
        android:text="@string/common_test"
        android:textSize="50sp"/>

    <com.example.test.tv.ActualTextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:layout_marginTop="10dp"
        android:background="#00005555"
        android:includeFontPadding="true"
        android:letterSpacing="0"
        android:padding="0dp"
        android:singleLine="true"
        android:text="@string/common_test"
        android:textSize="50sp"/>

    <com.example.test.tv.ActualTextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:layout_marginTop="5dp"
        android:background="#00555500"
        android:includeFontPadding="true"
        android:letterSpacing="0"
        android:padding="0dp"
        android:singleLine="true"
        android:text="@string/common_test"
        android:textSize="50sp"/>

    <com.example.test.tv.ActualTextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:layout_marginTop="1dp"
        android:background="#00005555"
        android:includeFontPadding="true"
        android:letterSpacing="0"
        android:padding="0dp"
        android:singleLine="true"
        android:text="@string/common_test"
        android:textSize="50sp"/>

    <com.example.test.tv.ActualTextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:layout_marginTop="0dp"
        android:background="#00555500"
        android:includeFontPadding="true"
        android:letterSpacing="0"
        android:padding="0dp"
        android:singleLine="true"
        android:text="@string/common_test"
        android:textSize="50sp"/>
</LinearLayout>
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章