一種簡單優雅的TextView行間距適配方案

背景

Android的屏幕碎片化嚴重,各類屏幕分辨率層出不窮,而在不一樣分辨率的屏幕上顯示出一致的效果,是百度App的研發團隊和視覺團隊共同追求的目標。java

在百度App的Android開發中,TextView的行間距屏幕適配問題在研發和視覺之間糾纏已久。
三款設備效果不一樣.jpegandroid

該圖爲熱議頁面的圖文模板在三款設備上的顯示效果。能夠看到TextView的行間距在三款設備下的一致性表現不盡如人意,而這已成爲平常UI開發以及視覺review過程當中的一大痛點,下降了你們的工做效率。數組

下面將探索一種簡單優雅的的TextView行間距適配方案。微信

分析

先來分析下TextView在不一樣設備上行間距表現不一致的緣由。百度App的UI團隊使用Sketch工具來進行UI設計以及UI review,所以本文接下來字體尺寸的測量都藉助Sketch工具完成。ide

先看下面一個簡單的xml佈局:函數

<TextView
        android:id="@+id/title"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="雖然此視圖的實際佈局取決於其父視圖和任何同級視圖中的其餘屬性。雖然。。。"
        android:textSize="16dp"/>

將這段代碼運行在不一樣分辨率的機型上,藉助Sketch工具測量出各機型的行間距以下:
不一樣機型行間距比較.jpeg工具

從圖中可看出,一樣的字號大小,在分辨率爲720設備上,行間距測量結果爲5px;在分辨率爲1080設備上,行間距測量結果爲9px。源碼分析

接下來修改下字號,將textSize改爲24dp,而且看一下Mate20的效果:佈局

<TextView
        android:id="@+id/title"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="雖然此視圖的實際佈局取決於其父視圖和任何同級視圖中的其餘屬性。雖然。。。"
        android:textSize="24dp"/>

在同一款設備(Mate 20)上,不一樣的字號,行間距的測量結果,以下圖所示:字體

同款設備不一樣字號行間距比較.jpeg

從圖中可看到,在一樣的設備上,不一樣的字號,行間距的測量結果也不同。

具體表現爲:字號越大,行間距越大。這就讓人很是苦惱了,由於一旦字號發生了變化,行間距就受到影響,行間距必須得跟隨字號從新調整,無形之中就增長了額外的工做量。

讀到這你們可能會有疑問:XML佈局中並沒設置lineSpacingExtra / lineSpacingMultiplier屬性,那麼上面所測量的行間距是哪來的呢?

這是由於視覺對行間距的定義和Android系統對行間距的定義不一致致使的。視覺層面定義行間距很是簡單:即便用Sketch工具在上下相鄰的兩行文字中輸入大小相同的文字,同時畫出文字的矩形框,矩形框的高度爲文字的大小,好比在1080P,density=3的設計圖中,文字大小爲16dp,那麼矩形框的高度就設爲48px。上下兩個矩形框的間距就爲文字的行間距,這從上面的測量效果圖也可看出。

也就是說,即便沒有設置lineSpacingExtra / lineSpacingMultiplier屬性,但從視覺的角度來說,仍存在必定的行間距。

那麼在沒有設置lineSpacingExtra / lineSpacingMultiplier屬性的狀況下,視覺所測量出來的行間距是什麼緣由致使的?下面結合TextView源碼詳細分析下,首先看下圖:
文字繪製關鍵座標信息.png

該圖展現了一行文字的繪製所須要的關鍵座標信息,圖中的幾根線表示字體的度量信息,在源碼中與其相對應的類爲FontMetrics.java,代碼以下:

/**
 * Class that describes the various metrics for a font at a given text size.
 * Remember, Y values increase going down, so those values will be positive,
 * and values that measure distances going up will be negative. This class
 * is returned by getFontMetrics().
 */
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;
}

代碼中對字體度量信息的每一個字段含義的解釋很是詳細,你們看註釋便可,就再也不過多解釋。TextView對每行文字座標信息的計算細節是在StaticLayout.java類中的out()方法完成的,代碼以下:

private int out(final CharSequence text, final int start, final int end, int above, int below,
        int top, int bottom, int v, final float spacingmult, final float spacingadd,
        final LineHeightSpan[] chooseHt, final int[] chooseHtv, final Paint.FontMetricsInt fm,
        final boolean hasTab, final int hyphenEdit, final boolean needMultiply,
        @NonNull final MeasuredParagraph measured,
        final int bufEnd, final boolean includePad, final boolean trackPad,
        final boolean addLastLineLineSpacing, final char[] chs,
        final int widthStart, final TextUtils.TruncateAt ellipsize, final float ellipsisWidth,
        final float textWidth, final TextPaint paint, final boolean moreChars) {
    final int j = mLineCount;
    // 偏移量,標識當前的行號
    final int off = j * mColumns;
    final int want = off + mColumns + TOP;
    // 一維數組,保存了TextView各行文字的計算出來的座標信息。
    int[] lines = mLines;
    final int dir = measured.getParagraphDir();
  
    // 將全部的字體的度量信息存入fm變量中,而後經過LineHeightSpan接口將fm變量傳遞出去.
    // 這就給外部提供了一個接口去修改字體的度量信息。
    if (chooseHt != null) {
        fm.ascent = above;
        fm.descent = below;
        fm.top = top;
        fm.bottom = bottom;

        for (int i = 0; i < chooseHt.length; i++) {
            if (chooseHt[i] instanceof LineHeightSpan.WithDensity) {
                ((LineHeightSpan.WithDensity) chooseHt[i])
                        .chooseHeight(text, start, end, chooseHtv[i], v, fm, paint);
            } else {
                chooseHt[i].chooseHeight(text, start, end, chooseHtv[i], v, fm);
            }
        }
        // 獲取修改後的字體度量屬性
        above = fm.ascent;
        below = fm.descent;
        top = fm.top;
        bottom = fm.bottom;
    }
    
    if (firstLine) {
        if (trackPad) {
            mTopPadding = top - above;
        }

        if (includePad) {
            // 若是當前行是TextView的第一行文字,above(ascent)值使用top替代。
            above = top;
        }
    }

    int extra;

    if (lastLine) {
        if (trackPad) {
            mBottomPadding = bottom - below;
        }

        if (includePad) {
            // 若是當前行是TextView的最後一行文字,below(descent)值使用bottom替代。
            below = bottom;
        }
    }

    if (needMultiply && (addLastLineLineSpacing || !lastLine)) {
        // 計算行間距
        // spacingmult變量對應lineSpacingMultiplier屬性配置的值
        // spacingadd變量對應lineSpacingExtra屬性配置的值。
        double ex = (below - above) * (spacingmult - 1) + spacingadd;

        if (ex >= 0) {
            extra = (int)(ex + EXTRA_ROUNDING);
        } else {
            extra = -(int)(-ex + EXTRA_ROUNDING);
        }
    } else {
        extra = 0;
    }

    // 將當前行的座標信息存入mLines[]數組中
    lines[off + START] = start;
    lines[off + TOP] = v;
    lines[off + DESCENT] = below + extra;
    lines[off + EXTRA] = extra;

    // 計算下一行的的top值
    v += (below - above) + extra;
    
    mLineCount++;
    return v;
}

因爲篇幅緣由,省略了一些無關代碼。上面對關鍵代碼都給出了詳細的註釋,這裏就不過多解釋。經過第87行,可得出以下兩個公式:

  • top座標計算:下一行Top值 = 本行Top值 + 行高
  • 行高計算(排除第一行和最後一行):行高 = descent - ascent + 行間距 (descent值爲正,ascent值爲負)

爲了方便你們理解行高,我把每行文字的baseline和top這兩根線畫了出來,紅色的線是baseline基線,綠色的線是top線,相鄰兩條綠線之間的距離即爲行高,以下圖所示:
行高解釋.jpeg

到這裏,基本可以解釋,在沒有設置lineSpacingExtra / lineSpacingMultiplier屬性的狀況下,Sketch工具量出的行間距緣由:咱們知道每行文字以baseline做爲基線來繪製,在ascent範圍內繪製基線以上的部分,在descent範圍內繪製基線如下部分。因爲漢字不會像英文那樣高低不一,是很是整齊的方塊字。

而漢字在descent範圍內繪製基線如下部分時,並無佔滿descent全部空間,會空出一部分距離,在ascent範圍內繪製基線以上部分時,也是一樣的道理。因此,Sketch測量出來的行間距就是上一行漢字佔據的descent範圍後的剩餘空間加上下一行漢字佔據的ascent範圍後的剩餘空間

適配方案

通過上面分析,瞭解到TextView的自帶行間距是因爲繪製的漢字沒有佔滿descent和ascent的空間引發的,且該行間距在不一樣的字號以及分辨率下表現不一。若可以去除掉這部分行間距,就能達到適配目的。怎麼去除呢?咱們再看一下系統TextView和視覺對一行文字行高的定義:

  • TextView:行高 = descent - ascent (descent值爲正,ascent值爲負)
  • 視覺:行高 = 字體大小 (好比16dp的文字,行高=48px)

只要可以修改TextView的默認行高,讓其和視覺定義的行高保持統一,就能去除掉這部分行間距。怎麼修改TextView的默認行高呢?其實TextView在設計的時候,提供了一個接口去修改TextView的行高。回到上面對TextView的源碼分析,第20行-第39行,將字體的度量信息存入fm變量中,而後經過LineHeightSpan接口將fm變量傳遞出去,咱們藉助這個LineHeightSpan就能夠修改TextView的行高。

最終適配方案以下:

public class ExcludeInnerLineSpaceSpan implements LineHeightSpan {
    // TextView行高
    private final  int mHeight;

    public ExcludeInnerPaddingSpan(int height) {
        mHeight = height;
    }

    @Override
    public void chooseHeight(CharSequence text, int start, int end,
                             int spanstartv, int lineHeight,
                             Paint.FontMetricsInt fm) {
        // 原始行高
        final int originHeight = fm.descent - fm.ascent;
        if (originHeight <= 0) {
            return;
        }
        // 計算比例值
        final float ratio = mHeight * 1.0f / originHeight;
        // 根據最新行高,修改descent
        fm.descent = Math.round(fm.descent * ratio);
        // 根據最新行高,修改ascent
        fm.ascent = fm.descent - mHeight;
    }
}

類ExcludeInnerLineSpaceSpan實現LineHeightSpan接口,這個類用於去除TextView的自帶行間距。第5行,構造函數,以最新的行高做爲參數傳入。第14行,計算出原始行高。第19行,計算出新行高和原始行高的比例值。第21行-第23行,根據比例值修改字體度量的ascent參數和descent參數。

接下來自定義個TextView出來,提供一個setCustomText()方法出來,供使用方調用。代碼以下:

public class ETextView extends TextView {
    /**
     * 排除每行文字間的padding
     *
     * @param text
     */
    public void setCustomText(CharSequence text) {
        if (text == null) {
            return;
        }

        // 得到視覺定義的每行文字的行高
        int lineHeight = (int) getTextSize();

        SpannableStringBuilder ssb ;
        if (text instanceof SpannableStringBuilder) {
            ssb = (SpannableStringBuilder) text;
            // 設置LineHeightSpan
            ssb.setSpan(new ExcludeInnerLineSpaceSpan(lineHeight),
                    0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
        } else {
            ssb = new SpannableStringBuilder(text);
            // 設置LineHeightSpan
            ssb.setSpan(new ExcludeInnerLineSpaceSpan(lineHeight),
                    0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
        }

        // 調用系統setText()方法
        setText(ssb);
    }
}

ShowCase

該方案使用系統公開API,簡單,侵入性低。並已在百度App熱議頁面上線,適配效果先後對比,以下圖所示:
適配前效果對比.jpeg
適配後效果對比.jpeg


本文做者:
zhanghao


在微信-搜索頁面中輸入「百度App技術」,便可關注微信官方帳號;或使用微信識別如下二維碼,亦可關注。
微信連接.jpg

相關文章
相關標籤/搜索