Android 富文本TextView

一、怎么用?

先介绍TextView富文本的使用方法,TextView富文本显示主要有两种方式,一个是使用SpannableString类,另一种是直接将富文本写成HTML形式。

SpannableString

SpannableString是Android内置的专门处理富文本的类,基本涵盖了你能想到的所有富文本表示,字体、颜色、图片、点击事件…功能非常强大。话不多说,直接上代码:

示例

//设置Hello World前三个字符为红色,背景为蓝色
SpannableString textSpanned1 = new SpannableString("Hello World");
textSpanned1.setSpan(new ForegroundColorSpan(Color.RED),
        0, 3, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
textSpanned1.setSpan(new BackgroundColorSpan(Color.BLUE),
        0, 3, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
text1.setText(textSpanned1);

//设置Hello World前三个字符字体为斜体
SpannableString textSpanned2 = new SpannableString("Hello World");
textSpanned2.setSpan(new StyleSpan(Typeface.ITALIC),
        0, 3, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
text2.setText(textSpanned2);

//设置Hello World前三个字符有下划线
SpannableString textSpanned3 = new SpannableString("Hello World");
textSpanned3.setSpan(new UnderlineSpan(),
        0, 3, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
text3.setText(textSpanned3);

//设置Hello World前三个字符有点击事件
SpannableStringBuilder textSpanned4 = new SpannableStringBuilder("Hello World");
ClickableSpan clickableSpan = new ClickableSpan() {
    @Override
    public void onClick(View view) {
        Toast.makeText(MainActivity.this, "Hello World", Toast.LENGTH_SHORT).show();
    }
};
textSpanned4.setSpan(clickableSpan,
        0, 3, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
//注意:此时必须加这一句,不然点击事件不会生效
text4.setMovementMethod(LinkMovementMethod.getInstance());
text4.setText(textSpanned4);
Spannable演示

setSpan()

void setSpan (Object what, int start, int end, int flags)
名称 说明
what 样式
start 样式开始的字符索引
end 样式结束的字符索引
flags 新插入字符的设置

flags:

取值 说明
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE 前后都不包括
Spanned.SPAN_EXCLUSIVE_INCLUSIVE 前面不包括,后面包括
Spanned.SPAN_INCLUSIVE_EXCLUSIVE 前面包括,后面不包括
Spanned.SPAN_INCLUSIVE_INCLUSIVE 前后都包括

这个flags可能有人不懂,它表示了这个样式是否作用在本字符串之前或之后插入的其他字符串上,举个例子:

SpannableStringBuilder textSpannedBuilder1 = new SpannableStringBuilder();
SpannableString textSpanned11 = new SpannableString("Hello");
textSpanned11.setSpan(new BackgroundColorSpan(Color.BLUE), 0, textSpanned11.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
SpannableString textSpanned12 = new SpannableString("World");
text1.setText(textSpannedBuilder1.append(textSpanned11).append(textSpanned12));

SpannableStringBuilder textSpannedBuilder2 = new SpannableStringBuilder();
SpannableString textSpanned21 = new SpannableString("Hello");
textSpanned21.setSpan(new BackgroundColorSpan(Color.BLUE), 0, textSpanned21.length(), Spanned.SPAN_EXCLUSIVE_INCLUSIVE);
SpannableString textSpanned22 = new SpannableString("World");
text2.setText(textSpannedBuilder2.append(textSpanned21).append(textSpanned22));

SpannableStringBuilder textSpannedBuilder3 = new SpannableStringBuilder();
SpannableString textSpanned31 = new SpannableString("Hello");
textSpanned31.setSpan(new BackgroundColorSpan(Color.BLUE), 0, textSpanned21.length(), Spanned.SPAN_EXCLUSIVE_INCLUSIVE);
SpannableString textSpanned32 = new SpannableString("World");
textSpanned32.setSpan(new BackgroundColorSpan(Color.GREEN), 0, 3, Spanned.SPAN_EXCLUSIVE_INCLUSIVE);
text3.setText(textSpannedBuilder3.append(textSpanned31).append(textSpanned32));
Flags演示
  • text1里,"Hello"的flags是SPAN_EXCLUSIVE_EXCLUSIVE,在它之后插入的"World"显示正常,无背景。
  • text2里,"Hello"的flags是SPAN_EXCLUSIVE_INCLUSIVE,它之后插入的"World"的背景变为蓝色。
  • 需要注意的是text3,这里的"Hello"与text2相同,而"World"的一部分字符设置为绿色,显然这部分字符显示的是绿色,这说明虽然设置了SPAN_EXCLUSIVE_INCLUSIVE属性,但只要后面的字符串设置了同类的样式,还是覆盖掉flags属性。

SpannableString和SpannableStringBuilder

在上面的例子中我们用到了SpannableStringBuilder类,那这个类和SpannableString有什么不同呢?其实大家只要想想String和StringBuilder区别就行了,SpannableString在创建的时候就需要指定好字符串,之后就不能更改了,而SpannableStringBuilder可以使用append()方法,在已有的富文本后添加新的富文本。

HTML

接下来介绍HTML的用法,其实HTML使用起来要比SpannableString简洁,我们只需要按照平时写HTML的习惯,将需要显示的富文本加上各种标签,就可以显示在TextView上了,下面我们看一下例子:

示例
String htmlText1 = "<b>Hello World</b>";
text1.setText(Html.fromHtml(htmlText1));

String htmlText2 = "<font color='#ff0000'>Hello World</font>";
text2.setText(Html.fromHtml(htmlText2));

String htmlText3 = "<i><a href='https://gavinli369.github.io/'>我的博客</a></i>";
text3.setMovementMethod(LinkMovementMethod.getInstance());
text3.setText(Html.fromHtml(htmlText3));
HTML演示

是不是感觉比SpannableString简单多了,其实Html类内部还是在使用Spannable处理,我们会在后文看到它的实现方式。

TextView支持的HTML标签
名称 说明
font 设置字体和颜色
big 大号字体
small 小号字体
i 斜体
b 粗体
tt 等宽字体
br 换行(行与行之间没有空行)
p 换行(行与行之间有空行)
a 链接
img 图像

其实TextView支持HTML标签不止这些,后文会带大家一起看的HTML类的源码,里面有TextView支持的所有HTML标签。还有一点需要注意的是,不同的标签是有可能会出现相同效果的,例如strong标签和b标签的效果都是字体加粗,这些在大家看到HTML类源码的时候,就知道会原因。

二、深入的探索

熟悉了用法,我们就要向更深一层探索了。接下来就让我来带大家深入TextView源码,一起揭开TextView富文本显示的神秘面纱......

Spannable的表示

首先,要想知道TextView的富文本是怎么实现的,我们得先搞明白Android内部是怎么表示富文本的,这是Spannable相关类的继承体系:


Spannable继承体系

我们之前用的SpannableString类和SpannableStringBuilder类都实现了Spannable接口,setSpan()方法就是在这里声明的。再看左边的SpannableString类,它继承自一个虚类SpannableStringInternal,而我们要找的富文本实现方法就隐藏在这个类中,下面我们就来一探究竟。

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++;
}

还是先进到我们最熟悉的setSpan()方法内看一下,我们发现setSpan方法主要更改了三个全局变量的值mSpans, mSpanData和mSpanCount,我们找到这几个变量的声明:

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;

其实SpannableStringInternal内部存在两个数组,一个mSpanData表示样式的首尾索引和flags,另一个mSpans表示对应的样式。
这个mSpanData的表示比较有意思,它是将三个变量打包存在一起的,取得时候只需要取变量对应的偏移地址的值,可以看一下这个mSpanData数组的表示图:


mSpanData构成

而SpannableStringBuilder的就显得简单多了,直接将这四个变量分别存放在了四个数组里,这里就不对它做过多介绍了,有兴趣的同学可以自己探索一下。

富文本的绘制

知道了富文本是怎么表示的,接下来我们就是富文本的绘制了,我们先看一下TextView的onDraw()方法。

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);
    }

}

这里就牵扯到了TextView类的构成,大家在看TextView源码的时候会发现有一万多行的代码,其实这是Android为了方便TextView的扩展,将很多本不该属于TextView的代码也写在了这里。大家可以看一下EditText的源码,总共就一百多行,大部分逻辑都是直接交给TextView处理的。而这个mEditor就是用来处理可编辑的TextView的,我们不管它直接看下面,TextView将绘制的细节都交给了这个mLayout来做,那这个mLayout又是什么呢?

protected Layout makeSingleLayout(int wantWidth, BoringLayout.Metrics boring, int ellipsisWidth,
        Layout.Alignment alignment, boolean shouldEllipsize, TruncateAt effectiveEllipsize,
        boolean useSaved) {
    Layout result = null;
    if (mText instanceof Spannable) {
        result = new DynamicLayout(mText, mTransformed, mTextPaint, wantWidth,
                alignment, mTextDir, mSpacingMult, mSpacingAdd, mIncludePad,
                mBreakStrategy, mHyphenationFrequency,
                getKeyListener() == null ? effectiveEllipsize : null, ellipsisWidth);
    }

    //省略一大段代码,其中包括另外两个Layout类的实例化,BoringLayout和StaticLayout

    return result;
}

在我一番寻找过后,发现mLayout就是在这里被创建的,我省略了另外两个Layout子类创建的代码,BoringLayout和StaticLayout,其实这三个类都是直接调用了它们父类Layout的draw()方法,而draw()类又调用了drawText()方法进行文本绘制,所以,我们还是直接进drawText()方法吧:

Layout:段落格式计算

public void drawText(Canvas canvas, int firstLine, int lastLine) {
    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++) {

        //这里省略了一些段落格式的计算,AlignmentSpan,LeadingMarginSpan都是在这里
        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;
        if (align == Alignment.ALIGN_NORMAL) {
            if (dir == DIR_LEFT_TO_RIGHT) {
                x = left + getIndentAdjust(lineNum, Alignment.ALIGN_LEFT);
            } else {
                x = right + getIndentAdjust(lineNum, Alignment.ALIGN_RIGHT);
            }
        } else {
            int max = (int)getLineExtent(lineNum, tabStops, false);
            if (align == Alignment.ALIGN_OPPOSITE) {
                if (dir == DIR_LEFT_TO_RIGHT) {
                    x = right - max + getIndentAdjust(lineNum, Alignment.ALIGN_RIGHT);
                } else {
                    x = left - max + getIndentAdjust(lineNum, Alignment.ALIGN_LEFT);
                }
            } else { // Alignment.ALIGN_CENTER
                max = max & ~1;
                x = ((right + left - max) >> 1) +
                        getIndentAdjust(lineNum, Alignment.ALIGN_CENTER);
            }
        }

        paint.setHyphenEdit(getHyphen(lineNum));
        Directions directions = getLineDirections(lineNum);
        if (directions == DIRS_ALL_LEFT_TO_RIGHT && !mSpannedText && !hasTab) {
            // 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);
        }
        paint.setHyphenEdit(0);
    }

}

在这里Layout就已经计算好了每一行的段落格式,前面空多少、居中还是靠右,而具体的文字显示样式则交给了TextLine类来处理。

TextLine:文本绘制

private float handleText(TextPaint wp, int start, int end,
        int contextStart, int contextEnd, boolean runIsRtl,
        Canvas c, float x, int top, int y, int bottom,
        FontMetricsInt fmi, boolean needWidth, int offset) {

    //...

    if (c != null) {
        //...

        //文字背景
        if (wp.bgColor != 0) {
            int previousColor = wp.getColor();
            Paint.Style previousStyle = wp.getStyle();

            wp.setColor(wp.bgColor);
            wp.setStyle(Paint.Style.FILL);
            c.drawRect(x, top, x + ret, bottom, wp);

            wp.setStyle(previousStyle);
            wp.setColor(previousColor);
        }

        if (wp.underlineColor != 0) {
            // kStdUnderline_Offset = 1/9, defined in SkTextFormatParams.h
            float underlineTop = y + wp.baselineShift + (1.0f / 9.0f) * wp.getTextSize();

            int previousColor = wp.getColor();
            Paint.Style previousStyle = wp.getStyle();
            boolean previousAntiAlias = wp.isAntiAlias();

            wp.setStyle(Paint.Style.FILL);
            wp.setAntiAlias(true);

            wp.setColor(wp.underlineColor);
            c.drawRect(x, underlineTop, x + ret, underlineTop + wp.underlineThickness, wp);

            wp.setStyle(previousStyle);
            wp.setColor(previousColor);
            wp.setAntiAlias(previousAntiAlias);
        }

        //文字绘制,内部直接调用canvas的drawTextRun()
        drawTextRun(c, wp, start, end, contextStart, contextEnd, runIsRtl,
                x, y + wp.baselineShift);
    }

    return runIsRtl ? -ret : ret;
}

就在这里TextView把将要显示的文本绘制到了canvas上,可能又有细心的同学发现了,这TextPaint的各项参数又是怎么来的呢?这我们就要回到调用它的方法handleRun()寻找答案了:

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;
}

是不是有种拨开云雾见青天的感觉,至此TextView使用SpannableString绘制富文本的整个流程就呈现在大家眼前了。

使用Html绘制富文本

有同学就要说了,不是说还有Html类的解析吗?其实Html内部还是将文本转换成了Spannable,原理都是相同的,我这里摘一段大家看一下:

if (tag.equalsIgnoreCase("strong")) {
    end(mSpannableStringBuilder, Bold.class, new StyleSpan(Typeface.BOLD));
} else if (tag.equalsIgnoreCase("b")) {
    end(mSpannableStringBuilder, Bold.class, new StyleSpan(Typeface.BOLD));
} else if (tag.equalsIgnoreCase("em")) {
    end(mSpannableStringBuilder, Italic.class, new StyleSpan(Typeface.ITALIC));
} else if (tag.equalsIgnoreCase("cite")) {
    end(mSpannableStringBuilder, Italic.class, new StyleSpan(Typeface.ITALIC));
} else if (tag.equalsIgnoreCase("dfn")) {
    end(mSpannableStringBuilder, Italic.class, new StyleSpan(Typeface.ITALIC));
} else if (tag.equalsIgnoreCase("i")) {
    end(mSpannableStringBuilder, Italic.class, new StyleSpan(Typeface.ITALIC));
} else if (tag.equalsIgnoreCase("big")) {
    end(mSpannableStringBuilder, Big.class, new RelativeSizeSpan(1.25f));
} else if (tag.equalsIgnoreCase("small")) {
    end(mSpannableStringBuilder, Small.class, new RelativeSizeSpan(0.8f));
}

这也解答了之前说的,为什么不同的标签却会产生相同效果的疑问。

以上就是这篇文章的全部内容了,如有疏漏,请多包涵。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,793评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,567评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,342评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,825评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,814评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,680评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,033评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,687评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,175评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,668评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,775评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,419评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,020评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,978评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,206评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,092评论 2 351
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,510评论 2 343

推荐阅读更多精彩内容