TextView图文混排居中、图片被截断、自动换行问题

概述

在Android中做图文混排,一般都是选择用TextView+ImageSpan来实现,但是在ImageSpan中,对齐方式只有ImageSpan.ALIGN_BASELINE,ImageSpan.ALIGN_BOTTOM两种对齐方式,不能相对于文本居中,如果我们图片小于文字,或者大于文字,都会导致排版问题出现。

nocenter
blocked

居中

下面是抄自wangkunlin同学都一个居中实现代码,封装的很好,就拿过来直接用了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
package com.wkl.imagespan;

import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.support.annotation.IntDef;
import android.text.style.ImageSpan;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.ref.WeakReference;

/**
* Created by wangkunlin
* On 2017-03-15
*/

public class AlignImageSpan extends ImageSpan {

/**
* 顶部对齐
*/
public static final int ALIGN_TOP = 3;
/**
* 垂直居中
*/
public static final int ALIGN_CENTER = 4;

@IntDef({ALIGN_BOTTOM, ALIGN_BASELINE, ALIGN_TOP, ALIGN_CENTER})
@Retention(RetentionPolicy.SOURCE)
public @interface Alignment {
}

public AlignImageSpan(Drawable d) {
this(d, ALIGN_CENTER);
}

public AlignImageSpan(Drawable d, @Alignment int verticalAlignment) {
super(d, verticalAlignment);
}

@Override
public int getSize(Paint paint, CharSequence text, int start, int end, Paint.FontMetricsInt fm) {
Drawable d = getCachedDrawable();
Rect rect = d.getBounds();
if (fm != null) {
Paint.FontMetrics fmPaint = paint.getFontMetrics();
// 顶部 leading
float topLeading = fmPaint.top - fmPaint.ascent;
// 底部 leading
float bottomLeading = fmPaint.bottom - fmPaint.descent;
// drawable 的高度
int drHeight = rect.height();

switch (mVerticalAlignment) {
case ALIGN_CENTER: { // drawable 的中间与 行中间对齐
// 当前行 的高度
float fontHeight = fmPaint.descent - fmPaint.ascent;
// 整行的 y方向上的中间 y 坐标
float center = fmPaint.descent - fontHeight / 2;

// 算出 ascent 和 descent
float ascent = center - drHeight / 2;
float descent = center + drHeight / 2;

fm.ascent = (int) ascent;
fm.top = (int) (ascent + topLeading);
fm.descent = (int) descent;
fm.bottom = (int) (descent + bottomLeading);
break;
}
case ALIGN_BASELINE: { // drawable 的底部与 baseline 对齐
// 所以 ascent 的值就是 负的 drawable 的高度
float ascent = -drHeight;
fm.ascent = -drHeight;
fm.top = (int) (ascent + topLeading);
break;
}
case ALIGN_TOP: { // drawable 的顶部与 行的顶部 对齐
// 算出 descent
float descent = drHeight + fmPaint.ascent;
fm.descent = (int) descent;
fm.bottom = (int) (descent + bottomLeading);
break;
}
case ALIGN_BOTTOM: // drawable 的底部与 行的底部 对齐
default: {
// 算出 ascent
float ascent = fmPaint.descent - drHeight;
fm.ascent = (int) ascent;
fm.top = (int) (ascent + topLeading);
}
}
}
return rect.right;
}

/**
* 这里的 x, y, top 以及 bottom 都是基于整个 TextView 的坐标系的坐标
*
* @param x drawable 绘制的起始 x 坐标
* @param top 当前行最高处,在 TextView 中的 y 坐标
* @param y 当前行的 BaseLine 在 TextView 中的 y 坐标
* @param bottom 当前行最低处,在 TextView 中的 y 坐标
*/
@Override
public void draw(Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, Paint paint) {
Drawable drawable = getDrawable();
Rect rect = drawable.getBounds();
float transY;
switch (mVerticalAlignment) {
case ALIGN_BASELINE:
transY = y - rect.height();
break;
case ALIGN_CENTER:
transY = ((bottom - top) - rect.height()) / 2 + top;
break;
case ALIGN_TOP:
transY = top;
break;
case ALIGN_BOTTOM:
default:
transY = bottom - rect.height();
}
canvas.save();
// 这里如果不移动画布,drawable 就会在 Textview 的左上角出现
canvas.translate(x, transY);
drawable.draw(canvas);
canvas.restore();
}

private Drawable getCachedDrawable() {
WeakReference<Drawable> wr = mDrawableRef;
Drawable d = null;

if (wr != null)
d = wr.get();

if (d == null) {
d = getDrawable();
mDrawableRef = new WeakReference<>(d);
}

return d;
}

private WeakReference<Drawable> mDrawableRef;
}

截断处理

图片被截断,在概述里我们说明了原因,那怎么解决呢?这里我们使用了TextUtils.ellipsize,它相当于 TextView的xml中ellipsize,是在获取TextView已setMaxLines后,显示出来的带有…结尾的内容。

所以我们就可以通过计算,获取到最终显示的文本,然后直接显示了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
//获取图片大小
int wh = mContext.getResources().getDimensionPixelOffset(R.dimen.28);
textView.post(() -> {
int width = textView.getWidth(); //获取textview宽度
int padding = textView.getPaddingLeft()+textView.getPaddingRight();//获取textview 内边距
int lineWidth = width - padding-wh*2;//获取实际展示的但航内容宽度
//通过TextUtils.ellipsize获取截断后的实际文本
String ellipsizeStr = TextUtils.ellipsize(signature, textView.getPaint(), lineWidth*2, TextUtils.TruncateAt.END).toString();
textView.setText(getSignatureText(". "+ellipsizeStr+" .",wh));
});


private SpannableString getSignatureText(String selfDesc,int wh){
Drawable drawableLeft = mContext.getResources().getDrawable(R.drawable.mark_left);
drawableLeft.setBounds(0, 0, wh, wh);
AlignImageSpan imgSpanLeft = new AlignImageSpan(drawableLeft, AlignImageSpan.ALIGN_CENTER);

Drawable drawableRight = mContext.getResources().getDrawable(R.drawable.mark_right);
drawableRight.setBounds(0, 0, wh, wh);
AlignImageSpan imgSpanRight = new AlignImageSpan(drawableRight, AlignImageSpan.ALIGN_CENTER);

SpannableString spannableString = new SpannableString(selfDesc);
spannableString.setSpan(imgSpanLeft, 0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
spannableString.setSpan(imgSpanRight, selfDesc.length()-1, selfDesc.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
return spannableString;
}

注意

“. “+ellipsizeStr+” .”,我们在这里加入图片的地方加了点和空格,点是为了占位,空格是为了设置与文字之间的间距。为啥要用点占位,不加占位的话,首个文字会被开始的图片给截取点,导致少展示了一个开始的文字。(但是直接用系统ImageSpan,不用TextUtils.ellipsize的时候就不会,感兴趣的朋友可以验证下原因)。

自动换行后,结尾图片显示正常

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
textView.post(() -> {
int ellipsisCount = textView.getLayout().getEllipsisCount(textView.getLineCount() - 1);
if (ellipsisCount > 0) {
int endIndex = getSubLength(text,textView,ellipsisCount);
text = text.substring(0, endIndex) + "... img";
}
});

/**
* 计算字符截取的point length,可以处理混排换行或数字,字母结尾图片展示不全的情况
* @param text
* @param textView
* @param ellipsisCount
* @return
*/
private int getSubLength(String text, TextView textView, int ellipsisCount) {
int endIndex = text.length() - ellipsisCount;
//默认减去一个字符代替图片位置
if (textView == null || textView.getPaint() == null) {
return endIndex - 1;
}
//精确计算需要展示的字符length
String subText = text.substring(0, endIndex);
char[] chars = subText.toCharArray();
float textWidth = 0;
Paint paint = textView.getPaint();
float point = paint.measureText("...");
for (int i = chars.length - 1; ; i--) {
textWidth = textWidth + paint.measureText(String.valueOf(chars[i]));
endIndex = i;
if (textWidth >= mImgSize + point) {
break;
}
}
return endIndex;
}

Html.fromHtml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
String texts = "<img src=\"startimg\"/>&nbsp;&nbsp;" + text +"...&nbsp;&nbsp;<img src=\"endimg\"/>";
Spanned spanned = Html.fromHtml(texts, new Html.ImageGetter() {
@Override
public Drawable getDrawable(String source) {
if("startimg".equals(source)){
//根据id从资源文件中获取图片对象
Drawable d = mContext.getResources().getDrawable(R.drawable.friend_list_card_selfdesc_mark_left);
d.setBounds(0, 0, wh,wh);
return d;
}else{
Drawable d = mContext.getResources().getDrawable(R.drawable.friend_list_card_selfdesc_mark_right);
d.setBounds(0, 0, wh,wh);
return d;
}
}
},null);
textView.setText(spanned);

html也不支持混排。

自动换行

通过计算每个字符的宽度,来自动添加\n换行,且xml中不需要设置ellipsize。暂支持只两行,其他情况原理相同。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
textView.post(() -> {
String endText = getAutoSplitText(textView,signature,2);
textView.setText(getSignatureText(endText,wh));
});

private String getAutoSplitText(TextView textView, String signature, int maxLineCount) {
int imgSize = mContext.getResources().getDimensionPixelOffset(R.dimen.px28);
String rawText = signature.replaceAll("\r", "");
final Paint tvPaint = textView.getPaint();
float space = tvPaint.measureText(" ");
float dot = tvPaint.measureText("...");
float tvWidth = textView.getWidth() - textView.getPaddingLeft() - textView.getPaddingRight(); //控件可用宽度
//如果整行宽度超过控件可用宽度,则按字符测量,在超过可用宽度的前一个字符处手动换行
StringBuilder startText = new StringBuilder();
float lineWidth = imgSize + space;
int lineCount = 0;
for (int count = 0; count < rawText.length(); ++count) {
char ch = rawText.charAt(count);
lineWidth += tvPaint.measureText(String.valueOf(ch));
if (lineWidth <= tvWidth) {
startText.append(ch);
} else {
startText.append("\n");
lineCount++;
if (lineCount == maxLineCount) {
break;
}
tvWidth = tvWidth - imgSize - space - dot - tvPaint.measureText(String.valueOf(ch));
lineWidth = 0;
--count;
}
}

//把结尾多余的\n去掉
if (startText.toString().endsWith("\n")) {
startText.deleteCharAt(startText.length() - 1);
}

StringBuilder endText = new StringBuilder();
if(lineCount < 2){
endText.append("img ").append(startText).append(" img");
}else{
endText.append("img ").append(startText).append("... img");
}
return endText.toString();
}

最终效果

nodot
dot

参考资料

TextView 图文混排,解决图片被截断

TextView图文混排图片被截断的问题以及Android省略号只有一个点的问题

SpannableString的用法详解