安卓中最长使用的控件就是TextView,一般而言,使用时只是简单的设置文字,大小,颜色,尺寸。稍微复杂一些的,我们使用Span标签,Drawable***等富文本。可能为了显示效果,还会进行paddingmargin调整,以及 跑马灯 效果的展示。

有时我们可能需要设置文字的行间隔,于是就用了lineSpacingExtra , lineSpacingMultiplier
有时想设置文字拉伸效果,于是会配置textScaleX 或者更进一步,想要修改文本间隔,这时需要设置letterSpacing 。。。

类似的情况还有很多,不过这些不是目前考虑的重点,事实上以上的内容虽然比较繁琐(单从上万行的 TextView 源码便可以看出该控件的功能性),但大多有规律可循,因此并不难使用。

比较麻烦的是,平时进行开发时,我们参照的UI设计图(一般为ps原件)一般都是这个样子 的 :

android两段文字 android textview 字间距_安卓

可以直接测量出两个TextView之间的间距为33dp,但在实际开发中,这里是无法照抄该尺寸的,TextView在绘制文字的时候,会自带一些padding效果,因此按照33dp显示出来的效果将会比设计图上大的多。

通常情况,开发人员在指定间隔时,会采用比该值小一些的,比较规范的间隔,比如常用的一些间隔尺寸:16dp32dp48dp64dp24dp,虽然大体上效果类似,但不免有些‘碓答案’的嫌疑。

如果要明白其中的猫腻,就需要去了解一些文字的显示方式了

1、TextView中文字绘制的规则?

在自定义View控件时,都会了解到Canvas的存在,我们看到的效果都是直接在画布上draw出来的,文字也不例外。

TextView中会保持一个画笔:Paint,我们可以在任何地方拿到该对象

TextPaint paint = tv.getPaint();

TextPaintPaint的子类,文本的显示等效果都是由该画笔Canvas上勾勒出来的,绘制文字的方法有这些:

android两段文字 android textview 字间距_TextView_02

无论哪个重载的方法,都需要传入一个坐标值:(x,y)

这个坐标是文字绘制的起点,但并不是文字左下角的坐标,已TextPaint定义的规则来看的话,大概是这个样子:

android两段文字 android textview 字间距_android两段文字_03

这里列举了多种类型的文本信息包括大小写,上标,表情等;图中显示出了五条线,限定了文字绘制的模版,代表的含义可以查看 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 之间的距离

android两段文字 android textview 字间距_TextView_04

上面的结果正好验证了我们之前的结论,即:

  • 在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值,这也是上面要做出诸多限制的原因。

为了显示效果清晰一些,我们加上一些带透明度的背景。

android两段文字 android textview 字间距_安卓_05

六个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,查看布局情况:

android两段文字 android textview 字间距_控件_06

可以看到倒数第二条绿线比较粗,这是因为在设置了 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>