TextView在Android开发中随处可见,Google 大大也封装的很完善。 But,扛不住形形色色的产品使劲儿的折腾,终于还是得自己上手了
TextView 留白移除
常规操作:
/**
* Set whether the TextView includes extra top and bottom padding to make
* room for accents that go above the normal ascent and descent.
* The default is true.
*
* @see #getIncludeFontPadding()
*
* @attr ref android.R.styleable#TextView_includeFontPadding
*/
public void setIncludeFontPadding(boolean includepad) {
if (mIncludePad != includepad) {
mIncludePad = includepad;
if (mLayout != null) {
nullLayouts();
requestLayout();
invalidate();
}
}
}
设置行间距后,最后一行底部仍有行间距问题
/**
* Description: 修正设置行间距后,最后一行底部仍有行间距问题
* User: luyongyong
* Date: 2021/3/12
*/
public class FixLastLineSpaceTextView extends TextView {
private final String TAG = FixLastLineSpaceTextView.class.getSimpleName();
private Rect mLastLineShowRect;
private Rect mLastLineActualIndexRect;
public FixLastLineSpaceTextView(Context context) {
super(context);
init();
}
public FixLastLineSpaceTextView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
public FixLastLineSpaceTextView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
private void init(){
mLastLineShowRect = new Rect();
mLastLineActualIndexRect = new Rect();
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
//去除显示的最后一行的行间距
//设置行间距 && 设置MaxLines && 实际行数大于MaxLines时,显示的最后一行会增加行间距
//Redmi 3(android 5.1.1)
//HuaWei nova youth(EMUI 5.1 andorid 7.0)
//oppo R7(ColorOs v2.1 android 4.4.4),只要设置了间距,默认最后一行都会增加间距
setMeasuredDimension(getMeasuredWidth(),getMeasuredHeight() - calculateExtraSpace());
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
}
@TargetApi(Build.VERSION_CODES.JELLY_BEAN)
private int calculateExtraSpace(){
int lastRowSpace = 0;
if(getLineCount()>0){
//实际最后一行
int actualLastRowIndex = getLineCount() - 1;
//显示的最后一行
int lastRowIndex = Math.min(getMaxLines(),getLineCount()) - 1;
if(lastRowIndex >= 0){
Layout layout = getLayout();
//显示的最后一行文字基线坐标
int baseline = getLineBounds(lastRowIndex, mLastLineShowRect);
getLineBounds(actualLastRowIndex, mLastLineActualIndexRect);
//测量显示的高度(measureHeight)等于TextView实际高度(layout.getHeight())或者等于实际高度减去不可见部分的高度(mLastLineActualIndexRect.bottom - mLastLineShowRect.bottom)
if (getMeasuredHeight() == layout.getHeight() - (mLastLineActualIndexRect.bottom - mLastLineShowRect.bottom)) {
lastRowSpace = mLastLineShowRect.bottom - (baseline + layout.getPaint().getFontMetricsInt().descent);
}else if(getMinLines() > getLineCount()){
lastRowSpace = mLastLineShowRect.bottom - (baseline + layout.getPaint().getFontMetricsInt().descent);
}
// Log.e(TAG,"lastRowIndex:"+lastRowIndex+",actualLastRowIndex:"+actualLastRowIndex+",lineCount:"+getLineCount());
// Log.e(TAG,"baseline:"+baseline);
// Log.e(TAG,"descent:"+layout.getPaint().getFontMetricsInt().descent);
// Log.e(TAG,"getMeasuredHeight():"+getMeasuredHeight());
// Log.e(TAG,"layout.getHeight():"+layout.getHeight());
// Log.e(TAG,"mLastLineActualIndexRect.bottom:"+mLastLineActualIndexRect.bottom);
// Log.e(TAG,"mLastLineShowRect.bottom:"+mLastLineShowRect.bottom);
// Log.e(TAG,"lastRowSpace:"+lastRowSpace);
// Log.e(TAG,"-----------------------------------");
}
}
return lastRowSpace;
}
}
TextView处理...省略号异常
参考资料来源:Android TextView实现查看全部和收起功能
难点在于:
- 如何在
setText()
之前判断处理文字是否超过了最大的限制行数 - 如何获取超过限制行数最后一个文字的下标
解决以上两个问题需要用到一个处理TextView文本排版,拆行处理的工具类SaticLayout
,SaticLayout
构造函数很多,但最终回调用这个构造函数
/**
* - CharSequence source 文本内容
* - int bufstart, int bufend, 开始位置和结束位置
* - TextPaint paint 文本画笔对象
* - int outerwidth 布局宽度,超出宽度换行显示
* - Alignment align 对齐方式
* - TextDirectionHeuristic textDir 文本显示方向
* - float spacingmult 行间距倍数,默认是1
* - float spacingadd 行距增加值,默认是0
* - boolean includepad 文本顶部和底部是否留白
* - TextUtils.TruncateAt ellipsize 文本省略方式,有 START、MIDDLE、 END、MARQUEE 四种省略方式
* - int ellipsizedWidth 省略宽度
* - int maxLines 最大行数
*/
public StaticLayout(CharSequence source, int bufstart, int bufend,
TextPaint paint, int outerwidth,
Alignment align, TextDirectionHeuristic textDir,
float spacingmult, float spacingadd,
boolean includepad,
TextUtils.TruncateAt ellipsize,
int ellipsizedWidth, int maxLines)
在构造函数中最后会相继调用generate()
和out()
方法,对文本进行拆行处理。如果需要详细了解StaticLayout
的工作原理,可参考StaticLayout 源码分析
然后我们可以通过调用getLineCount()
方法获取到布局该文本的行数,调用getLineStart(int line)
方法可以获取line下一行第一个文字的下标。
下面是具体实现的相关代码:
private int maxLine = 3;
private SpannableString elipseString;//收起的文字
private SpannableString notElipseString;//展开的文字
private void getLastIndexForLimit(TextView tv, int maxLine, String content) {
//获取TextView的画笔对象
TextPaint paint = tv.getPaint();
//每行文本的布局宽度
int width =getResources().getDisplayMetrics().widthPixels - dip2px(this,40);
//实例化StaticLayout 传入相应参数
StaticLayout staticLayout = new StaticLayout(content,paint,width, Layout.Alignment.ALIGN_NORMAL, 1, 0, false);
//判断content是行数是否超过最大限制行数3行
if (staticLayout.getLineCount()>maxLine) {
//定义展开后的文本内容
String string1 = content + " 收起";
notElipseString = new SpannableString(string1);
//给收起两个字设成蓝色
notElipseString.setSpan(new ForegroundColorSpan(Color.parseColor("#0079e2")), string1.length() - 2, string1.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
//获取到第三行最后一个文字的下标
int index = staticLayout.getLineStart(maxLine) - 1;
//定义收起后的文本内容
String substring = content.substring(0, index - 4) + "..." + "查看全部";
elipseString = new SpannableString(substring);
//给查看全部设成蓝色
elipseString.setSpan(new ForegroundColorSpan(Color.parseColor("#0079e2")), substring.length() - 4, substring.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
//设置收起后的文本内容
tv.setText(elipseString);
tv.setOnClickListener(this);
//将textview设成选中状态 true用来表示文本未展示完全的状态,false表示完全展示状态,用于点击时的判断
tv.setSelected(true);
} else {
//没有超过 直接设置文本
tv.setText(content);
tv.setOnClickListener(null);
}
}
/**
* 根据手机的分辨率从 dp 的单位 转成为 px(像素)
*/
public static int dip2px(Context mContext, float dpValue) {
final float scale = mContext.getResources().getDisplayMetrics().density;
return (int) (dpValue * scale + 0.5f);
}
TextView行间距适配
- 具体现象:
在同样的设备上,不同的字号,行间距的测量结果也不一样。
具体表现为:字号越大,行间距越大。这就让人非常苦恼了,因为一旦字号发生了变化,行间距就受到影响,行间距必须得跟随字号重新调整,无形之中就增加了额外的工作量。
文字的绘制所需要的关键坐标信息
/**
* 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;
}
StaticLayout计算每行坐标信息
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;
}
可得出如下两个公式:
top坐标计算:下一行Top值 = 本行Top值 + 行高
行高计算(排除第一行和最后一行):行高 = descent - ascent + 行间距 (descent值为正,ascent值为负)
-
系统TextView和视觉对一行文字行高的定义:
-
TextView:行高 = descent - ascent (descent值为正,ascent值为负)
-
视觉:行高 = 字体大小 (比如16dp的文字,行高=48px)
-
只要能够修改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);
}
}
设置maxlines和Ellipsize=end后,如果本截断的文本后边是\n,还有显示下一行文本
/**
* 解决TextView设置maxlines和Ellipsize=end后,如果截断的文本后边是\n,还有显示下一行文本的问题
*
* @author wangbing
* @date 2019-08-27
*/
public class EllipsizeTextView extends TextView {
private static final String YX_THREE_DOTS = "...";
private static final int YX_THREE_DOTS_LENGTH = YX_THREE_DOTS.length();
private SpannableStringBuilder mSpannableStringBuilder;
private boolean flag = false;
public EllipsizeTextView(Context context) {
super(context);
}
public EllipsizeTextView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
public EllipsizeTextView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
protected void onDraw(Canvas canvas) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
final Layout layout = getLayout();
if (flag && layout != null && layout.getLineCount() >= getMaxLines()) {
CharSequence charSequence = getText();
int lastCharDown = layout.getLineVisibleEnd(getMaxLines() - 1);
if (lastCharDown >= YX_THREE_DOTS_LENGTH && charSequence.length() > lastCharDown) {
if (mSpannableStringBuilder == null) {
mSpannableStringBuilder = new SpannableStringBuilder();
} else {
mSpannableStringBuilder.clear();
}
mSpannableStringBuilder.append(charSequence.subSequence(0, lastCharDown))
.append(YX_THREE_DOTS);
setText(mSpannableStringBuilder);
flag = false;
}
}
}
super.onDraw(canvas);
}
@Override
public void setText(CharSequence text, BufferType type) {
flag = true;
super.setText(text, type);
}
}