TextView多行文字超出时如何在省略号后添加图标

TextView的图文混排知识

Posted by Tristan on May 29, 2019

背景

最近在做feed流模板样式调整时,出现了这样一个需求,要求在标题文字后面(不是控件后面)加一个图片标签来展示精帖。标题文字控制最大显示两行,文字超出部分用省略号代替,图标紧跟在文字或是省略号后显示。

技术点

图文混排方案

Android官方对TextView的图文混排提供了支持,我们可以从以下三种方案实现TextView的图文混排:

  1. 在TextView中使用Compound Drawable属性;
  2. 在TextView中显示HTML文本;
  3. 在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()方法定义了如何编辑字符串,通过它可以设置一些格式对象(例如字体、颜色、图片、点击事件等),这样就实现富文本显示了。

SpannableStringSpannableStringBuilder的区别就好比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属性管理了文本的布局展示,同时也提供了两个能力,

  1. 获取省略号在当前行的开始位置;
  2. 获取当前行内被省略文字的长度。
/**
 * 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的对其方式都是以底部对其的。

  1. span的底部和text的底部对齐;
  2. 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);
    }
});

总结

  1. TextView的实现代码惯用模板方法这个设计模式,好处是便于扩展,同时有模板作用,实现它的子类充分做到了小而精。
  2. 了解了富文本的实现原理后,对TextView的能力基本已经有了掌握,后续需要扩展实现个性化的需求便有了技术积累。

文献

Android图文混排实现方式详解
Android TextView富文本深入探索
设计模式(22)–Template Method(模板方法模式)–行为型
Android:文字渲染layout整理
【Android Drawable】一、Drawable 类
不可不知的开发技巧之View.Post()