背景
最近在做feed流模板样式调整时,出现了这样一个需求,要求在标题文字后面(不是控件后面)加一个图片标签来展示精帖。标题文字控制最大显示两行,文字超出部分用省略号代替,图标紧跟在文字或是省略号后显示。
技术点
图文混排方案
Android官方对TextView的图文混排提供了支持,我们可以从以下三种方案实现TextView的图文混排:
- 在TextView中使用Compound Drawable属性;
- 在TextView中显示HTML文本;
- 在TextView中使用Spannable多样式显示。
方案1
实现效果其实跟在控件后面(周围)加图片标签的性质是一样的,文字多行显示时无法满足图标紧跟文字显示的需求,单行标题的模板可以使用此方案。
方案2
是通过将HTML内容转化为Spanned格式在TextView中进行显示,它的输出是SpannableStringBuilder,实现原理跟方案3
一样。它有特定的应用场景,所以此处不做过多描述,列出核心源码便可以理解。
public Spanned convert() {
mReader.setContentHandler(this);
try {
mReader.parse(new InputSource(new StringReader(mSource)));
} catch (IOException e) {
// We are reading from a string. There should not be IO problems.
throw new RuntimeException(e);
} catch (SAXException e) {
// TagSoup doesn't throw parse exceptions.
throw new RuntimeException(e);
}
// Fix flags and range for paragraph-type markup.
Object[] obj = mSpannableStringBuilder.getSpans(0, mSpannableStringBuilder.length(), ParagraphStyle.class);
for (int i = 0; i < obj.length; i++) {
int start = mSpannableStringBuilder.getSpanStart(obj[i]);
int end = mSpannableStringBuilder.getSpanEnd(obj[i]);
// If the last line of the range is blank, back off by one.
if (end - 2 >= 0) {
if (mSpannableStringBuilder.charAt(end - 1) == '\n' &&
mSpannableStringBuilder.charAt(end - 2) == '\n') {
end--;
}
}
if (end == start) {
mSpannableStringBuilder.removeSpan(obj[i]);
} else {
mSpannableStringBuilder.setSpan(obj[i], start, end, Spannable.SPAN_PARAGRAPH);
}
}
return mSpannableStringBuilder;
}
方案3
的输出格式是SpannableString,它和方案2
的SpannableStringBuilder都实现了CharSequence接口,TextView的setText()方法传入参数其实就是这个接口类型;同时它们又都实现了Spannable接口,Spannable的setSpan()方法定义了如何编辑字符串,通过它可以设置一些格式对象(例如字体、颜色、图片、点击事件等),这样就实现富文本显示了。
SpannableString
和SpannableStringBuilder
的区别就好比String和StringBuilder,前者在创建的时候就需要指定好字符串,之后就不能更改了,而后者可以使用append()方法,在已有的富文本后添加新的富文本。
图文混排实现
图片替换指定文本实现图文混排的逻辑还是比较简单的:创建一个SpannableString对象,将文本传入,然后把文本中的占位符通过图片样式ImageSpan替换即可。以下是具体的逻辑实现:
String text = guessLikeBean.getTitle()+"(精)";// 标题文本,通过"(精)"占位
...
SpannableString imageString = new SpannableString(text);// 创建SpannableString对象
Drawable image = mContext.getResources().getDrawable(R.drawable.ic_recommend_job_jing2);// 获取图片资源
ImageSpan imageSpan = new ImageSpan(image);// 图片样式
imageString.setSpan(imageSpan, imageString.length()-3, imageString.length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE);// 图片替换占位符
mTvTitle.setText(imageString);// 给TextView设置图文
SpannableString的setSpan()方法参数解释:
void setSpan(Object what, int start, int end, int flags)
参数 | 说明 |
---|---|
what | 样式对象 |
start | 样式开始的字符索引 |
end | 样式结束的字符索引 |
flags | 新插入字符的设置 |
这样简单的图文混排就已经实现了;但是存在一个问题:如果文本过长,末尾的图标同样和文字一样也会被省略号替代。图标紧跟省略号显示的需求此时还不能满足。
富文本原理
1. 富文本表示
SpannableString
继承自SpannableStringInternal
,通过模板方法设计模式,将其大部分的逻辑转移到了父类身上;setSpan()接口的实现其实主要是这个父类完成的,这样也是为了后续在更换实现方案时会有个模板类提供依据和步骤。
模板方法模式是类的行为模式。准备一个抽象类,将部分逻辑以具体方法以及具体构造函数的形式实现,然后声明一些抽象方法来迫使子类实现剩余的逻辑。不同的子类可以以不同的方式实现这些抽象方法,从而对剩余的逻辑有不同的实现。这就是模板方法模式的用意。
private String mText;
private Object[] mSpans;
private int[] mSpanData;
private int mSpanCount;
private static final int START = 0;
private static final int END = 1;
private static final int FLAGS = 2;
private static final int COLUMNS = 3;
void setSpan(Object what, int start, int end, int flags) {
...
mSpans[mSpanCount] = what;
mSpanData[mSpanCount * COLUMNS + START] = start;
mSpanData[mSpanCount * COLUMNS + END] = end;
mSpanData[mSpanCount * COLUMNS + FLAGS] = flags;
mSpanCount++;
}
SpannableStringInternal内部存在两个数组,一个mSpanData表示样式的首尾索引和flags,另一个mSpans表示对应的样式。
2. TextView绘制
View的绘制入口是onDraw()方法。TextView的逻辑比较复杂,绘制内容很多,这里只提取跟富文本相关的逻辑,其它比如“跑马灯”效果的绘制此处省略了;代码如下:
protected void onDraw(Canvas canvas) {
...
if (mLayout == null) {
assumeLayout();
}
Layout layout = mLayout;
...
final int cursorOffsetVertical = voffsetCursor - voffsetText;
Path highlight = getUpdatedHighlightPath();
if (mEditor != null) {
mEditor.onDraw(canvas, layout, highlight, mHighlightPaint, cursorOffsetVertical);
} else {
layout.draw(canvas, highlight, mHighlightPaint, cursorOffsetVertical);
}
...
}
Editors是EditText的内容绘制类,Layout才是管TextView的绘制类。
这里再说明一下,TextView的源码有一万多行,而EditText的源码只有一百多行,EditText把绘制逻辑都交给了TextView也是像之前提到的使用了模板方法设计模式。
3. 创建Layout
TextView发现设置的文本类型是Spannable就会创建DynamicLayout类型的Layout。
private void setTextInternal(@Nullable CharSequence text) {
mText = text;
mSpannable = (text instanceof Spannable) ? (Spannable) text : null;
mPrecomputed = (text instanceof PrecomputedText) ? (PrecomputedText) text : null;
}
public boolean useDynamicLayout() {
return isTextSelectable() || (mSpannable != null && mPrecomputed == null);
}
protected Layout makeSingleLayout(int wantWidth, BoringLayout.Metrics boring, int ellipsisWidth,
Layout.Alignment alignment, boolean shouldEllipsize, TruncateAt effectiveEllipsize,
boolean useSaved) {
Layout result = null;
if (useDynamicLayout()) {
final DynamicLayout.Builder builder = DynamicLayout.Builder.obtain(mText, mTextPaint,
wantWidth)
.setDisplayText(mTransformed)
.setAlignment(alignment)
.setTextDirection(mTextDir)
.setLineSpacing(mSpacingAdd, mSpacingMult)
.setIncludePad(mIncludePad)
.setUseLineSpacingFromFallbacks(mUseFallbackLineSpacing)
.setBreakStrategy(mBreakStrategy)
.setHyphenationFrequency(mHyphenationFrequency)
.setJustificationMode(mJustificationMode)
.setEllipsize(getKeyListener() == null ? effectiveEllipsize : null)
.setEllipsizedWidth(ellipsisWidth);
result = builder.build();
}
// 省略了其它两个Layout类的创建过程:BoringLayout和StaticLayout
return result;
}
DynamicLayout内部没有draw()方法实现,实现逻辑还是交给了抽象父类Layout完成,这里依然用到了上文提到的设计模式。
public abstract class Layout {
...
public void draw(Canvas canvas, Path highlight, Paint highlightPaint,
int cursorOffsetVertical) {
final long lineRange = getLineRangeForDraw(canvas);
int firstLine = TextUtils.unpackRangeStartFromLong(lineRange);
int lastLine = TextUtils.unpackRangeEndFromLong(lineRange);
if (lastLine < 0) return;
drawBackground(canvas, highlight, highlightPaint, cursorOffsetVertical,
firstLine, lastLine);
drawText(canvas, firstLine, lastLine);
}
}
拨云见雾,Layout的绘制方法中,富文本的逻辑主要是在drawText()方法中。
4. 段落格式计算
drawText()中有大量的段落格式计算,其中for循环遍历计算了每一行的显示格式,包括:上下左右、前后和方向。这些格式计算完,终于到了雾里看花的时刻,绘制逻辑可以看到最终是在TextLine封装类中实现的。
public void drawText(Canvas canvas, int firstLine, int lastLine) {
int previousLineBottom = getLineTop(firstLine);
int previousLineEnd = getLineStart(firstLine);
...
TextLine tl = TextLine.obtain();
// Draw the lines, one at a time.
// The baseline is the top of the following line minus the current line's descent.
for (int lineNum = firstLine; lineNum <= lastLine; lineNum++) {
int start = previousLineEnd;
previousLineEnd = getLineStart(lineNum + 1);
int end = getLineVisibleEnd(lineNum, start, previousLineEnd);
int ltop = previousLineBottom;
int lbottom = getLineTop(lineNum + 1);
previousLineBottom = lbottom;
int lbaseline = lbottom - getLineDescent(lineNum);
int dir = getParagraphDirection(lineNum);
int left = 0;
int right = mWidth;
...
// Determine whether the line aligns to normal, opposite, or center.
Alignment align = paraAlign;
if (align == Alignment.ALIGN_LEFT) {
align = (dir == DIR_LEFT_TO_RIGHT) ?
Alignment.ALIGN_NORMAL : Alignment.ALIGN_OPPOSITE;
} else if (align == Alignment.ALIGN_RIGHT) {
align = (dir == DIR_LEFT_TO_RIGHT) ?
Alignment.ALIGN_OPPOSITE : Alignment.ALIGN_NORMAL;
}
int x;
final int indentWidth;
if (align == Alignment.ALIGN_NORMAL) {
if (dir == DIR_LEFT_TO_RIGHT) {
indentWidth = getIndentAdjust(lineNum, Alignment.ALIGN_LEFT);
x = left + indentWidth;
} else {
indentWidth = -getIndentAdjust(lineNum, Alignment.ALIGN_RIGHT);
x = right - indentWidth;
}
} else {
int max = (int)getLineExtent(lineNum, tabStops, false);
if (align == Alignment.ALIGN_OPPOSITE) {
if (dir == DIR_LEFT_TO_RIGHT) {
indentWidth = -getIndentAdjust(lineNum, Alignment.ALIGN_RIGHT);
x = right - max - indentWidth;
} else {
indentWidth = getIndentAdjust(lineNum, Alignment.ALIGN_LEFT);
x = left - max + indentWidth;
}
} else { // Alignment.ALIGN_CENTER
indentWidth = getIndentAdjust(lineNum, Alignment.ALIGN_CENTER);
max = max & ~1;
x = ((right + left - max) >> 1) + indentWidth;
}
}
Directions directions = getLineDirections(lineNum);
if (directions == DIRS_ALL_LEFT_TO_RIGHT && !mSpannedText && !hasTab && !justify) {
// XXX: assumes there's nothing additional to be done
canvas.drawText(buf, start, end, x, lbaseline, paint);
} else {
tl.set(paint, buf, start, end, dir, directions, hasTab, tabStops);
tl.draw(canvas, x, ltop, lbaseline, lbottom);
}
}
TextLine.recycle(tl);
}
5. 富文本绘制
TextLine中的draw()、drawRun()和handleRun()三个方法,环环相扣,最终绘制出了TextView的每行文本。
/**
* Renders the TextLine.
*/
void draw(Canvas c, float x, int top, int y, int bottom) {
if (!mHasTabs) {
if (mDirections == Layout.DIRS_ALL_LEFT_TO_RIGHT) {
drawRun(c, 0, mLen, false, x, top, y, bottom, false);
return;
}
if (mDirections == Layout.DIRS_ALL_RIGHT_TO_LEFT) {
drawRun(c, 0, mLen, true, x, top, y, bottom, false);
return;
}
}
float h = 0;
int[] runs = mDirections.mDirections;
int lastRunIndex = runs.length - 2;
for (int i = 0; i < runs.length; i += 2) {
int runStart = runs[i];
int runLimit = runStart + (runs[i+1] & Layout.RUN_LENGTH_MASK);
if (runLimit > mLen) {
runLimit = mLen;
}
boolean runIsRtl = (runs[i+1] & Layout.RUN_RTL_FLAG) != 0;
int segstart = runStart;
for (int j = mHasTabs ? runStart : runLimit; j <= runLimit; j++) {
int codept = 0;
if (mHasTabs && j < runLimit) {
codept = mChars[j];
if (codept >= 0xD800 && codept < 0xDC00 && j + 1 < runLimit) {
codept = Character.codePointAt(mChars, j);
if (codept > 0xFFFF) {
++j;
continue;
}
}
}
if (j == runLimit || codept == '\t') {
h += drawRun(c, segstart, j, runIsRtl, x+h, top, y, bottom,
i != lastRunIndex || j != mLen);
if (codept == '\t') {
h = mDir * nextTab(h * mDir);
}
segstart = j + 1;
}
}
}
}
判断从左到右还是从右到左,按方向绘制文本。
/**
* Draws a unidirectional (but possibly multi-styled) run of text.
*/
private float drawRun(Canvas c, int start,
int limit, boolean runIsRtl, float x, int top, int y, int bottom,
boolean needWidth) {
if ((mDir == Layout.DIR_LEFT_TO_RIGHT) == runIsRtl) {
float w = -measureRun(start, limit, limit, runIsRtl, null);
handleRun(start, limit, limit, runIsRtl, c, x + w, top,
y, bottom, null, false);
return w;
}
return handleRun(start, limit, limit, runIsRtl, c, x, top,
y, bottom, null, needWidth);
}
最后,调到了统一绘制文本的方法;前面使用的ImageSpan图片样式正是CharacterStyle的实现类。
/**
* Utility function for handling a unidirectional run. The run must not
* contain tabs but can contain styles.
*/
private float handleRun(int start, int measureLimit,
int limit, boolean runIsRtl, Canvas c, float x, int top, int y,
int bottom, FontMetricsInt fmi, boolean needWidth) {
//...
for (int i = start, inext; i < measureLimit; i = inext) {
for (int j = i, jnext; j < mlimit; j = jnext) {
//...
wp.set(mPaint);
for (int k = 0; k < mCharacterStyleSpanSet.numberOfSpans; k++) {
//...
CharacterStyle span = mCharacterStyleSpanSet.spans[k];
//调用对应Style的updateDrawState()方法,直接设置TextPaint属性
span.updateDrawState(wp);
}
x += handleText(wp, j, jnext, i, inext, runIsRtl, c, x,
top, y, bottom, fmi, needWidth || jnext < measureLimit, offset);
}
}
return x - originalX;
}
至此,整个富文本的计算和绘制过程算是理出了一条脉络出来;然后再回过头来看看之前提到的问题怎么解决。
Layout属性
TextView的Layout属性管理了文本的布局展示,同时也提供了两个能力,
- 获取省略号在当前行的开始位置;
- 获取当前行内被省略文字的长度。
/**
* Return the offset of the first character to be ellipsized away,
* relative to the start of the line. (So 0 if the beginning of the
* line is ellipsized, not getLineStart().)
*/
public abstract int getEllipsisStart(int line);
/**
* Returns the number of characters to be ellipsized away, or 0 if
* no ellipsis is to take place.
*/
public abstract int getEllipsisCount(int line);
有了以上能力,针对上文最后提到的问题马上便会有思路:通过精确计算被省略的文字位置,截取字符串重新插入占位标识符,然后实现在省略号处添加图片,以确保“精”字图标不会因文字超出而被省略。
String text = guessLikeBean.getTitle()+"(精)";
mTvTitle.setText(text);
int ellipsisCount = mTvTitle.getLayout().getEllipsisCount(mTvTitle.getLineCount() - 1);
if (ellipsisCount > 0) {
text = text.substring(0, text.length() - ellipsisCount - 1) + "…(精)";
}
SpannableString imageString = new SpannableString(text);
...
Drawble属性
Drawble如果不设置Bounds属性,图片无法被绘制,如上占位符的地方显示的便是空白。
Drawble设置Bounds属性的方法如下:
public void setBounds(int left, int top, int right, int bottom);
public void setBounds(Rect bounds);
setBounds()主要设置绘制区域矩形的边界,draw()方法在绘制图片时会用到Bounds属性值,不设置默认边界均为0,所以使用Drawable时必须先设置这个属性。
标题的字体大小是17sp,因此将bounds的属性分别设置为:左,0dp;上,0dp;右,17dp;下,17dp。实现后的效果如下,发现图片在行内没有垂直居中显示,这一点达不到预期效果。
之后尝试修改参数,将top值改为负数,试图把图片的上边界向上扩展,但结果也都不达预期。
int left = DensityUtil.dip2px(mContext, 1);
int top = DensityUtil.dip2px(mContext, -1);
int right = DensityUtil.dip2px(mContext, 16);
int bottom = DensityUtil.dip2px(mContext, 17);
image.setBounds(left, top, right, bottom);
仔细考虑,可能是因为图片本身的尺寸比例是正方形,意图单纯通过修改上下边界参数无法做到图片垂直居中。最后,跳出单纯修改参数的思维,同时修改了图片的画布大小,在保留原始图片的基础上在其下方留白,然后配合以上边界值参数,最终得到了向好的结果。
自定义ImageSpan
其实,到目前为止,我们还没有搞清楚为什么图片会靠下,不能居中显示。
通过进一步查看ImageSpan的源码,发现其父类DynamicDrawableSpan中定义了两种垂直对齐方式;针对这两个常量官方也做了明确解释:"A constant indicating that the bottom of this span should be aligned with..."
——意思是span的对其方式都是以底部对其的。
- span的底部和text的底部对齐;
- span的底部和text的基线对齐。
public abstract class DynamicDrawableSpan extends ReplacementSpan {
/**
* A constant indicating that the bottom of this span should be aligned
* with the bottom of the surrounding text, i.e., at the same level as the
* lowest descender in the text.
*/
public static final int ALIGN_BOTTOM = 0;
/**
* A constant indicating that the bottom of this span should be aligned
* with the baseline of the surrounding text.
*/
public static final int ALIGN_BASELINE = 1;
protected final int mVerticalAlignment;
/**
* Creates a {@link DynamicDrawableSpan}. The default vertical alignment is
* {@link #ALIGN_BOTTOM}
*/
public DynamicDrawableSpan() {
mVerticalAlignment = ALIGN_BOTTOM;
}
@Override
public void draw(@NonNull Canvas canvas, CharSequence text,
@IntRange(from = 0) int start, @IntRange(from = 0) int end, float x,
int top, int y, int bottom, @NonNull Paint paint) {
Drawable b = getCachedDrawable();
canvas.save();
int transY = bottom - b.getBounds().bottom;
if (mVerticalAlignment == ALIGN_BASELINE) {
transY -= paint.getFontMetricsInt().descent;
}
canvas.translate(x, transY);
b.draw(canvas);
canvas.restore();
}
}
读懂了源码实现,其实就清楚为什么bounds参数怎么设置图片都无法居上显示了。顺理成章,按照源码中draw()方法的实现,可以对transY做一小改动从而实现图片居上显示的需求,这样也算是弥补了ImageSpan的一项缺陷。
public class AlignTopImageSpan extends ImageSpan {
/**
* A constant indicating that the top of this span should be aligned
* with the top of the surrounding text.
*/
public static final int ALIGN_TOP = 2;
public AlignTopImageSpan(Drawable drawable, int verticalAlignment) {
super(drawable, verticalAlignment);
}
@Override
public void draw(Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, Paint paint) {
Drawable b = getDrawable();
canvas.save();
int transY = bottom - b.getBounds().bottom;
if (mVerticalAlignment == ALIGN_BASELINE) {
transY -= paint.getFontMetricsInt().descent;
} else if (mVerticalAlignment == ALIGN_TOP) {
int textLength = text.length();
for (int i = 0; i < textLength; i++) {
// 如果是图片,则做向上偏移处理达到居上对齐的效果
if (Character.isBmpCodePoint(text.charAt(i))) {
transY -= paint.getFontMetricsInt().descent * 2;
break;
}
}
}
canvas.translate(x, transY);
b.draw(canvas);
canvas.restore();
}
}
比起上面直接修改图片的方案,其实这样自定义ImageSpan对开发者来说是更有意义的图片排版处理方案。
开发技巧
Layout中有大量的计算逻辑,它是异步执行的;在TextView还没有计算出它的高度前冒然调用layout的方法必然会发生奔溃现象。View的post()方法可以保证新任务是在layout调用过后执行,此处可以通过此方法避免该问题。
mTvTitle.post(new Runnable() {
@Override
public void run() {
int ellipsisCount = mTvTitle.getLayout().getEllipsisCount(mTvTitle.getLineCount() - 1);
...
mTvTitle.setText(imageString);
}
});
最后列出完整的代码实现供审阅。
mTvTitle.post(new Runnable() {
@Override
public void run() {
// 计算文字超出部分做精确截取
String text = guessLikeBean.getTitle()+"(精)";
mTvTitle.setText(text);
int ellipsisCount = mTvTitle.getLayout().getEllipsisCount(mTvTitle.getLineCount() - 1);
if (ellipsisCount > 0) {
text = text.substring(0, text.length() - ellipsisCount - 1) + "…(精)";
}
// 创建SpannableString对象
SpannableString imageString = new SpannableString(text);
// 获取图片资源并设置绘制边界
Drawable image = mContext.getResources().getDrawable(R.drawable.ic_recommend_job_jing2);
int left = DensityUtil.dip2px(mContext, 0);
int top = DensityUtil.dip2px(mContext, 0);
int right = DensityUtil.dip2px(mContext, 17);
int bottom = DensityUtil.dip2px(mContext, 17);
image.setBounds(left, top, right, bottom);
// 创建图片样式对象替换占位符
AlignTopImageSpan imageSpan = new AlignTopImageSpan(image, ALIGN_TOP);
imageString.setSpan(imageSpan, imageString.length()-3, imageString.length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE);
mTvTitle.setText(imageString);
}
});
总结
- TextView的实现代码惯用模板方法这个设计模式,好处是便于扩展,同时有模板作用,实现它的子类充分做到了小而精。
- 了解了富文本的实现原理后,对TextView的能力基本已经有了掌握,后续需要扩展实现个性化的需求便有了技术积累。
文献
Android图文混排实现方式详解
Android TextView富文本深入探索
设计模式(22)–Template Method(模板方法模式)–行为型
Android:文字渲染layout整理
【Android Drawable】一、Drawable 类
不可不知的开发技巧之View.Post()