美文网首页Android
如何去掉TextView最后一行底部行间距(2.0)

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

作者: lmz14 | 来源:发表于2022-01-06 15:04 被阅读0次

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

    <TextView
            android:id="@+id/tvUseTextView"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:lineSpacingExtra="5dp"
            android:maxLines="2"
            android:textColor="@color/grgray"
            android:text="男士衬衣2018秋季寸衣潮流休闲加厚加绒保暖韩版修身条纹长袖衬衫/男士衬衣2018秋季寸衣潮流休闲加厚加绒保暖韩版修身条纹长袖衬衫"/>
     <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:textColor="@android:color/holo_blue_bright"
            android:text="我是下方控件"/>
    

      
    现象:视觉可见最后一行底部存在间距
    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)

        本文链接:https://www.haomeiwen.com/subject/lldixrtx.html