如何去掉TextView最后一行底部行间距(2.0)

条件:设置行间距5dp && 设置MaxLines=2 && 实际行数3大于MaxLines


 

  
现象:视觉可见最后一行底部存在间距
android 4.4.4 、android 7.1.1 视觉可见最后一行(这里具体是第二行)存在行间距5dp
android 10.0 视觉可见最后一行(这里具体是第二行),存在间距,但是远小于我们设置的行间距

模拟器不同安卓版本截图1.jpeg

  
猜测:底部间距是行间距?
  

分析
android:lineSpacingExtra在TextView源码中对应的变量是mSpacingAdd,对mSpacingAdd进行搜索,发现mSpacingAdd会被传入BoringLayout、DynamicLayout、StaticLayout中,这三个都是Layout的子类。

Layout

BoringLayout:主要用于适配单行文字展示;测量文字宽度小于等于可展示的宽度(单行)
DynamicLayout:文字内容可选或者文本是Spannable时,可使用
StaticLayout:不符合BoringLayout和DynamicLayout的,都使用StaticLayout;所以多行文字的测量和布局可以看这个

TextView的测量绘制都是由Layout来完成的。

测量:获取每行的信息,保存在lines数组中
(1)Layout=null
onMessure ->makeNewLayout ->makeSingleLayout ->StaticLayout ->android.text.StaticLayout#generate
->android.text.StaticLayout#out
(2)layout != null
onMessure ->getDesiredHeight() -> android.text.Layout#getHeight

绘制:通过获取的每一行的测量信息进行文字绘制
(1)Layout=null
onDraw ->assumeLayout() ->makeNewLayout ->makeSingleLayout ->StaticLayout ->android.text.StaticLayout#generate
->android.text.StaticLayout#out ->android.text.Layout#draw(android.graphics.Canvas, android.graphics.Path, android.graphics.Paint, int)
->android.text.Layout#drawText
(2)layout != null
onDraw ->android.text.Layout#draw(android.graphics.Canvas, android.graphics.Path, android.graphics.Paint, int) ->android.text.Layout#drawText

android.text.StaticLayout#out函数源码分析

以下是android4.4.4、android7.1.1、android 10代码逻辑一致部分

(1)源码中变量与文字度量距离的对应关系

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;
  }
FontMetricsInt文字度量与above_below_top_bottom对应关系.png

(2)首尾行增加pading源码,三个版本代码逻辑一样

      if (firstLine) {
            if (trackPad) {
                mTopPadding = top - above;
            }

            if (includePad) {
            //above是文字度量的ascent,将top距离赋值给above,就是增加了ascent到基线base距离
                above = top;
            }
        }

        int extra;

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

            if (includePad) {
                below = bottom;
            }
        }

(3)每行信息保存,三个版本代码逻辑一样
  唯一不同的点:android10多了一个mMaxLineHeight,保存视觉可见最后一行(这里对应的是第二行)文字度量bottom的Y坐标高度,即视觉可见TextView的最大总高度

//相同部分
        lines[off + START] = start;
        lines[off + TOP] = v;
        lines[off + DESCENT] = below + extra;

        //下一行绘制的top = 当前top坐标 + 上一行的文字descent - 上一行文字的ascent + 行间距  (即 top Y坐标 + 文字高度(lastLine=true是会包含bottom和descent之间的距离) + 行间距)
        v += (below - above) + extra;
        lines[off + mColumns + START] = end;
        lines[off + mColumns + TOP] = v;

        // TODO: could move TAB to share same column as HYPHEN, simplifying this code and gaining
        // one bit for start field
        lines[off + TAB] |= flags & TAB_MASK;
        lines[off + HYPHEN] = flags;

        lines[off + DIR] |= dir << DIR_SHIFT;
//android 10源码
        lines[off + START] = start;
        lines[off + TOP] = v;
        lines[off + DESCENT] = below + extra;
        lines[off + EXTRA] = extra;

        // special case for non-ellipsized last visible line when maxLines is set
        // store the height as if it was ellipsized
        if (!mEllipsized && currentLineIsTheLastVisibleOne) {
            // below calculation as if it was the last line
            int maxLineBelow = includePad ? bottom : below;
            // similar to the calculation of v below, without the extra.
            mMaxLineHeight = v + (maxLineBelow - above);
        }

        v += (below - above) + extra;
        lines[off + mColumns + START] = end;
        lines[off + mColumns + TOP] = v;

  

各版本造成原因

1、android 4.4.4

(1)android.text.StaticLayout#out
  只要设置了行间距,都会在每一行底部增加行间距,若是实际文本最后一行文字,还会在最后一行文字底部增加pading(即bottom到descent之间的距离)。
  按我们上面的条件,视觉可见最后一行(这里具体是第二行)底部会增加一个5dp的行间距。

       if (j == 0) {
            if (trackPad) {
                mTopPadding = top - above;
            }

            if (includePad) {
                above = top;
            }
        }
        if (end == bufEnd) {
            //实际文本最后一行文字
            if (trackPad) {
                mBottomPadding = bottom - below;
            }

            if (includePad) {
                below = bottom;
            }
        }

        int extra;

        if (needMultiply) {
            //只要有设置了行间距,不论哪一行都会设置行间距(包括文本最后一行)
            double ex = (below - above) * (spacingmult - 1) + spacingadd;
            if (ex >= 0) {
                extra = (int)(ex + EXTRA_ROUNDING);
            } else {
                extra = -(int)(-ex + EXTRA_ROUNDING);
            }
        } else {
            extra = 0;
        }

(2)获取的视觉可见的总高度是最后一行再下一行的Top坐标高度,
视觉可见的最后一行非实际文本最后一行,所以这个top不包含bottom和descent之间的距离,包含我们设置的5dp的间距。
1)android.widget.TextView#onMeasure

 int desired = getDesiredHeight();

2)android.widget.TextView#getDesiredHeight(android.text.Layout, boolean)

    if (mMaxMode == LINES) {
            /*
             * Don't cap the hint to a certain number of lines.
             * (Do cap it, though, if we have a maximum pixel height.)
             */
            if (cap) {
                if (linecount > mMaximum) {
                //获取最大行的下一行Top坐标高度
                    desired = layout.getLineTop(mMaximum);

                    if (dr != null) {
                        desired = Math.max(desired, dr.mDrawableHeightLeft);
                        desired = Math.max(desired, dr.mDrawableHeightRight);
                    }

                    desired += pad;
                    linecount = mMaximum;
                }
            }
        }

  

2、android 7.1.1

  maxLine(即mMaximumVisibleLineCount)与ellipsize设置有关,未设置ellipsize时,不会往Layout的设置maxLine的值,所以mMaximumVisibleLineCount取的是默认值Integer.MAX_VALUE。
  按我们上面的条件,lastLine=false,会在我们视觉可见的最后一行(这里具体指第二行)底部增加5dp的行间距,下一行的Top坐标也不含bottom到descent的距离。
(1)android.widget.TextView#makeNewLayout

boolean shouldEllipsize = mEllipsize != null && getKeyListener() == null;

我们没有设置省略处理,所以mEllipsize=null,shouldEllipsize=false

(2)android.widget.TextView#makeSingleLayout

if (result == null) {
            StaticLayout.Builder builder = StaticLayout.Builder.obtain(mTransformed,
                    0, mTransformed.length(), mTextPaint, wantWidth)
                    .setAlignment(alignment)
                    .setTextDirection(mTextDir)
                    .setLineSpacing(mSpacingAdd, mSpacingMult)
                    .setIncludePad(mIncludePad)
                    .setBreakStrategy(mBreakStrategy)
                    .setHyphenationFrequency(mHyphenationFrequency);
            if (shouldEllipsize) {
                builder.setEllipsize(effectiveEllipsize)
                        .setEllipsizedWidth(ellipsisWidth)
                        .setMaxLines(mMaxMode == LINES ? mMaximum : Integer.MAX_VALUE);
            }
            // TODO: explore always setting maxLines
            result = builder.build();
        }

       // shouldEllipsize=false,不会调用setMaxLines,若有调用setMaxLines,mMaximum值会复制给android.text.StaticLayout#mMaximumVisibleLineCount
       // 由于这里没有调用setMaxLines,所以android.text.StaticLayout#mMaximumVisibleLineCount取的是默认值

前面我们已经知道了shouldEllipsize=false,所以不会调用setMaxLines,若有调用setMaxLines,mMaximum值会复制给android.text.StaticLayout#mMaximumVisibleLineCount。
由于这里没有调用setMaxLines,所以android.text.StaticLayout#mMaximumVisibleLineCount取的是默认值。

(3)android.text.StaticLayout#out

//private int mMaximumVisibleLineCount = Integer.MAX_VALUE;
boolean currentLineIsTheLastVisibleOne = (j + 1 == mMaximumVisibleLineCount);
boolean lastLine = currentLineIsTheLastVisibleOne || (end == bufEnd);
 if (needMultiply && !lastLine) {
            double ex = (below - above) * (spacingmult - 1) + spacingadd;
            if (ex >= 0) {
                extra = (int)(ex + EXTRA_ROUNDING);
            } else {
                extra = -(int)(-ex + EXTRA_ROUNDING);
            }
        } else {
            extra = 0;
        }
//mMaximumVisibleLineCount取的是默认值,所以在我们布局中设置的maxline的最大行,也就是第二行的时候,j + 1 != mMaximumVisibleLineCount,
//所以可见的最后一行lastline会被标记为false,底部会增加间距

mMaximumVisibleLineCount取的是默认值,所以在我们布局中设置的maxline的最大行,也就是第二行的时候,j + 1 != mMaximumVisibleLineCount,所以可见的最后一行lastline会被标记为false,底部会增加间距。

(4)获取的视觉可见的总高度是最后一行再下一行的Top坐标高度

1)android.widget.TextView#onMeasure

int desired = getDesiredHeight();

2)android.widget.TextView#getDesiredHeight(android.text.Layout, boolean)

if (mMaxMode == LINES) {
            /*
             * Don't cap the hint to a certain number of lines.
             * (Do cap it, though, if we have a maximum pixel height.)
             */
            if (cap) {
                if (linecount > mMaximum) {
                //获取最大行的下一行Top坐标高度
                    desired = layout.getLineTop(mMaximum);

                    if (dr != null) {
                        desired = Math.max(desired, dr.mDrawableHeightLeft);
                        desired = Math.max(desired, dr.mDrawableHeightRight);
                    }

                    desired += pad;
                    linecount = mMaximum;
                }
            }
        }

3)android.text.StaticLayout#getLineTop

 @Override
    public int getLineTop(int line) {
        return mLines[mColumns * line + TOP];
    }

3、android 10

(1)android.text.StaticLayout#out
  按我们上面的条件,视觉可见最后一行(这里具体是第二行),lastLine=false,在视觉可见最后一行增加行间距。
  mMaxLineHeight保存TextView可见的最大总高度,这个总高度包含了文字度量bottom到descent的距离,不包括视觉可见最后一行增加的行间距。

if (mEllipsized) {
            lastLine = true;
        } else {
            final boolean lastCharIsNewLine = widthStart != bufEnd && bufEnd > 0
                    && text.charAt(bufEnd - 1) == CHAR_NEW_LINE;
            if (end == bufEnd && !lastCharIsNewLine) {
            //bufEnd:实际文本结束位置  end:当前这一行文字结束位置
            //实际文本最后一行文字 && 最后一个字符非换行符,标记为最后一行,即实际文本最后一行
                lastLine = true;
            } else if (start == bufEnd && lastCharIsNewLine) {
            //当前行只有一个换行符,标记为最后一行,即实际文本最后一行
                lastLine = true;
            } else {
                lastLine = false;
            }
        }
.....此处省略
   if (needMultiply && (addLastLineLineSpacing || !lastLine)) {
            double ex = (below - above) * (spacingmult - 1) + spacingadd;
            if (ex >= 0) {
                extra = (int)(ex + EXTRA_ROUNDING);
            } else {
                extra = -(int)(-ex + EXTRA_ROUNDING);
            }
        } else {
            extra = 0;
        }
lines[off + START] = start;
        lines[off + TOP] = v;
        lines[off + DESCENT] = below + extra;
        lines[off + EXTRA] = extra;

        // special case for non-ellipsized last visible line when maxLines is set
        // store the height as if it was ellipsized
        if (!mEllipsized && currentLineIsTheLastVisibleOne) {
        //includePad默认为true,所以mMaxLineHeight=当前top的坐标 + 当前从文字度量bottom到ascent的距离
            // below calculation as if it was the last line
            int maxLineBelow = includePad ? bottom : below;
            // similar to the calculation of v below, without the extra.
            mMaxLineHeight = v + (maxLineBelow - above);
        }

        v += (below - above) + extra;
        lines[off + mColumns + START] = end;
        lines[off + mColumns + TOP] = v;

(2)按我们上面的条件,获取的视觉可见的总高度是mMaxLineHeight
1)android.widget.TextView#onMeasure

int desired = getDesiredHeight();

2)android.widget.TextView#getDesiredHeight(android.text.Layout, boolean)

int desired = layout.getHeight(cap);

3)android.text.StaticLayout#getHeight
  android 9(api 28)时启用以下方法
  实际文本行数大于maxLine设置的最大行数时,视觉可见的测量高度取的是mMaxLineHeight的值,这个值不包括视觉可见最后一行底部的行间距,但是包含文字度量bottom到descent之间的pading值,所以上面我们在android10上看到的最后一行底部有一个远小于我们设置的5dp行间距的距离。

 /**
     * Return the total height of this layout.
     *
     * @param cap if true and max lines is set, returns the height of the layout at the max lines.
     *
     * @hide
     */
    @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P, trackingBug = 115609023)
    public int getHeight(boolean cap) {
        if (cap && mLineCount > mMaximumVisibleLineCount && mMaxLineHeight == -1
                && Log.isLoggable(TAG, Log.WARN)) {
            Log.w(TAG, "maxLineHeight should not be -1. "
                    + " maxLines:" + mMaximumVisibleLineCount
                    + " lineCount:" + mLineCount);
        }

        return cap && mLineCount > mMaximumVisibleLineCount && mMaxLineHeight != -1
                ? mMaxLineHeight : super.getHeight();
    }

4)android10的mMaximumVisibleLineCount等于maxLine设置的值
  android7.1.1,maxLine的设置与省略设置Ellipsize有关系,如果没有设置Ellipsize,也不会往layout中设置maxLine,导致MaximumVisibleLineCount取的是默认值
  android10,maxLine的设置与省略设置Ellipsize没有任何关系了,有设置maxLine时,也会往layout中设置maxLine,MaximumVisibleLineCount = maxLine;

android 10 android.widget.TextView#makeSingleLayout源码

//android 10  android.widget.TextView#makeSingleLayout源
if (result == null) {
            StaticLayout.Builder builder = StaticLayout.Builder.obtain(mTransformed,
                    0, mTransformed.length(), mTextPaint, wantWidth)
                    .setAlignment(alignment)
                    .setTextDirection(mTextDir)
                    .setLineSpacing(mSpacingAdd, mSpacingMult)
                    .setIncludePad(mIncludePad)
                    .setUseLineSpacingFromFallbacks(mUseFallbackLineSpacing)
                    .setBreakStrategy(mBreakStrategy)
                    .setHyphenationFrequency(mHyphenationFrequency)
                    .setJustificationMode(mJustificationMode)
                    .setMaxLines(mMaxMode == LINES ? mMaximum : Integer.MAX_VALUE);
            if (shouldEllipsize) {
                builder.setEllipsize(effectiveEllipsize)
                        .setEllipsizedWidth(ellipsisWidth);
            }
            result = builder.build();
        }

  

解决方案

对系统测量的视觉可见总高度进行修正,有两种情况需要修复

1、系统测量的视觉高度是通过layout.getLineTop(mMaximum)获取的,需要扣除的高度 = 最后一行底部坐标到文字度量descent Y坐标之间的距离
2、系统测量的视觉高度是通过mMaxLineHeight获取的,需要扣除的高度 = 文字度量bottom与descent之间的距离

关键辅助方法

android.widget.TextView#getLineBounds
返回指定行的基线baseline的Y坐标,若传入bounds(Rect),当layout已经被创建的前提下,可以返回这一行精确的上下左右精确坐标

关键代码
public class LineSpaceExtraTextView extends AppCompatTextView {
    
    .....代码省略

    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        this.setMeasuredDimension(this.getMeasuredWidth(), getFixDesiredHeight());
    }

    private int getFixDesiredHeight(){
        int lastLineExtraSpace = this.calculateExtraSpace();
        return this.getMeasuredHeight() - lastLineExtraSpace;
    }

    private int calculateExtraSpace() {
        int lastRowSpace = 0;

        try {
            if (Build.VERSION.SDK_INT >= 16 && this.getLineCount() > 0) {
                int actualLastRowIndex = this.getLineCount() - 1;
                int lastRowIndex = Math.min(this.getMaxLines(), this.getLineCount()) - 1;
                if (lastRowIndex >= 0) {
                    Layout layout = this.getLayout();
                    //getLineBounds返回指定行的基线baseline的Y坐标,若传入bounds(Rect),当layout已经被创建的前提下,可以返回这一行精确的上下左右精确坐标
                    int baseline = this.getLineBounds(lastRowIndex, this.mLastLineShowRect);
                    this.getLineBounds(actualLastRowIndex, this.mLastLineActualIndexRect);
                    int extra = (int) getLineSpacingExtra();
                    Log.e(TAG,"===========START=======");
                    Log.e(TAG,"xml中设置的行间距LineSpacingExtra:"+extra);
                    Log.e(TAG,"视觉可见文本高度 MeasuredHeight:"+this.getMeasuredHeight());
                    Log.e(TAG,"文本总高度 Height:"+layout.getHeight());
                    Log.e(TAG,"===========Rect=======");
                    Log.e(TAG,"实际文本最后一行的底部Y坐标 ActualLastLineRect bottom:"+mLastLineActualIndexRect.bottom);
                    Log.e(TAG,"视觉可见最后一行的底部Y坐标 LastLineRect bottom:"+this.mLastLineShowRect.bottom);
                    int fontDescentY = baseline + layout.getPaint().getFontMetricsInt().descent;
                    int fontBottomY = baseline + layout.getPaint().getFontMetricsInt().bottom;
                    Log.e(TAG,"===========视觉可见最后一行 FontMetrics=======");
                    Log.e(TAG,"FontMetrics baseline Y坐标:"+baseline);
                    Log.e(TAG,"FontMetrics descent Y坐标:"+ fontDescentY);
                    Log.e(TAG,"FontMetrics bottom Y坐标:"+ fontBottomY);
                    if (this.getMeasuredHeight() == layout.getHeight() - (this.mLastLineActualIndexRect.bottom - this.mLastLineShowRect.bottom)) {
                        //getMeasuredHeight() 系统测量的TextView视觉可见高度
                        //实际未展示部分文本高度 = 实际最后一行的底部Y坐标 - 视觉可见最后一行的底部Y坐标
                        //手动计算视觉可见总高度 = 文本实际总高度 - 实际未展示部分文本高度
                        //api28以下,没有mMaxLineHeight,手动计算的视觉可将高度与系统测量出来的视觉可见高度一致时,说明系统测量的高度是通过layout.getLineTop(mMaximum)获取,需要对测量高度进行修复

                        //视觉可见最后一行底部行间距 = 最后一行底部Y坐标 - 文字度量descent的Y坐标
                        //得到的最后一行行间距,包括了xml中设置的行间距、文字度量的bottom与descent之间的间距
                        lastRowSpace = this.mLastLineShowRect.bottom - (baseline + layout.getPaint().getFontMetricsInt().descent);
                        Log.e(TAG,"视觉可见最后一行底部行间距(包括了xml中设置的行间距、文字度量的bottom与descent之间的间距):"+lastRowSpace);
                    }else{
                        //api28及以上,通过mMaxLineHeight修复TextView视觉可见高度,mMaxLineHeight包含了文字度量的bottom与descent之间的间距
                        //若系统测量的TextView视觉可见高度 == 文字度量Bottom的Y坐标高度,说明存在文字度量的bottom与descent之间的间距
                        if(this.getMeasuredHeight() == fontBottomY){
                            lastRowSpace = layout.getPaint().getFontMetricsInt().bottom - layout.getPaint().getFontMetricsInt().descent;
                            Log.e(TAG,"去掉文字度量的bottom与descent之间的间距:"+lastRowSpace);
                        }
                    }
                }
                Log.e(TAG,"===========END=======");
            }
            return lastRowSpace;
        } catch (Exception var6) {
            return lastRowSpace;
        }
    }
}

  

系统源码变量说明
trackPad、includePad:在xml中对应android:includeFontPadding,系统默认是true;作用:防止某些语言文字被裁剪,用于给首尾行默认加pading
mMaximumVisibleLineCount、mMaximum:在xml中对应android:maxLines
mEllipsize:在xml中对应android:ellipsize
  

参考资料:Android源码调试方法

版权声明: 转载请注明出处

你可能感兴趣的:(如何去掉TextView最后一行底部行间距(2.0))