前言
在开发App的过程中,有的时候需要后端返回包含Html标签的文本来实现文案样式的动态性或者有的时候我们也需要在一个TextView中同时展示不同样式的一段文本,比如“这是一段包含粗体以及<font color=#AEAAE4>多</font><font color=#FF11FF>种</font><font color=#1100AA>颜</font><font color=#AACC00>色</font>的文案”。我们知道Android中支持Html标签的使用——Html.fromHtml(Str)。但Android中的支持并不完善,比如我们可以用< font>标签实现字体颜色的变化,但是无法直接指定字号,只能通过< big>< small>标签去嵌套改变,这种方式一是麻烦,二是不能精确指定字体大小,只能不断嵌套去尝试多少个标签可以达到理想的效果。
我们知道Android中Html标签的处理也是通过匹配的对应xml结构文本中的标签内容,对SpannableString进行Span样式的设置。在Android 7.0之后Android系统的Html.fromHtml方法支持自定义TagHandler的使用,即支持自定义标签,也就说我们可以自定义去接收哪些属性去设置SpannableString。
代码
主要分了三个文件
BaseHtmlTag中封装了标签的处理抽象方法和公共常见的提取数值的方法,如字体字号、色值的提取、Span标签的获取。
public abstract class BaseHtmlTag {
private static final String UNIT_PX = "px";
protected static final String FONT_SIZE = "font-size";
protected static final String COLOR = "color";
protected static final String BACKGROUND_COLOR = "background-color";
protected static final String FONT_WEIGHT = "font-weight";
protected static final String BOLD = "bold";
protected static final String STYLE = "style";
/**
* 处理头标签<AAA>
*
* @param originEditable
* @param atts
*/
public abstract void startHandleTag(Editable originEditable, Attributes atts);
/**
* 处理尾标签</AAA>
*
* @param originEditable
*/
public abstract void endHandleTag(Editable originEditable);
/**
* </custom>标签结束处理
*
* @param originEditable
*/
public abstract void finishHandleTag(Editable originEditable);
public int getFontSize(String fontSize) {
if (TextUtils.isEmpty(fontSize)) {
return -1;
}
fontSize = fontSize.toLowerCase();
//单位px
if (fontSize.endsWith(UNIT_PX) && TextUtils.isDigitsOnly(fontSize.substring(0, fontSize.indexOf(UNIT_PX)))) {
return (int) Float.parseFloat(fontSize.substring(0, fontSize.indexOf(UNIT_PX)));
}
//无单位
if (TextUtils.isDigitsOnly(fontSize)) {
return (int) Float.parseFloat(fontSize);
}
return -1;
}
/**
* 重写Color.parseColor 不希望出现Exception
*
* @param colorString
* @return
*/
public int parseColor(String colorString) {
if (TextUtils.isEmpty(colorString)) {
return -1;
}
try {
return Color.parseColor(colorString);
} catch (IllegalArgumentException ex) {
return -1;
}
}
/**
* 获取editable中已经存在的span集合,获取最新添加的span
*
* @param start 匹配查询起点
* @param editable
* @param kind
*/
public static <T> T getLastSpanFromEdit(int start, Editable editable, Class<T> kind) {
T[] objs = editable.getSpans(start, editable.length(), kind);
if (objs.length == 0) {
return null;
} else {
return objs[objs.length - 1];
}
}
}
CustomSpanTag继承了BaseHtmlTag,主要实现了头标签、尾标签、自定义标签结束处理。
public class CustomSpanTag extends BaseHtmlTag {
public static final String SPAN = "span";
private final Stack<Integer> spanStartIndexStack = new Stack<>();
private final Stack<StashedSpanStyle> stashSpanStyleStack = new Stack<>();
@Override
public void startHandleTag(Editable originEditable, Attributes atts) {
String style = atts.getValue("", STYLE);
if (TextUtils.isEmpty(style)) {
return;
}
final String textColorStr = getValueFromStyle(style, COLOR);
final String fontSizeStr = getValueFromStyle(style, FONT_SIZE);
final String backgroundColorStr = getValueFromStyle(style, BACKGROUND_COLOR);
final String fontWeight = getValueFromStyle(style, FONT_WEIGHT);
final int fontSize = getFontSize(fontSizeStr);
boolean isFind = false;
if (fontSize != -1) {
//接收数据为px单位但因为iOS、Android使用单位不同且无法使用px实现适配,暂时采用15px当做15dp进行处理
setSpanStartIndex(originEditable, new FontSize(fontSize));
isFind = true;
}
final int textColor = parseColor(textColorStr);
if (textColor != -1) {
setSpanStartIndex(originEditable, new ForegroundColor(textColor));
isFind = true;
}
final int backgroundColor = parseColor(backgroundColorStr);
if (backgroundColor != -1) {
setSpanStartIndex(originEditable, new BackgroundColor(backgroundColor));
isFind = true;
}
if (fontWeight != null && fontWeight.toLowerCase().equals(BOLD)) {
setSpanStartIndex(originEditable, new Bold());
isFind = true;
}
if (isFind) {
spanStartIndexStack.push(originEditable.length());
}
}
private String getValueFromStyle(String style, String matchAttr) {
if (TextUtils.isEmpty(style)) {
return null;
}
return getHtmlCssAttrs(style, matchAttr);
}
private String getHtmlCssAttrs(@NonNull String style, String matchAttr) {
if (TextUtils.isEmpty(style)) {
return null;
}
String[] styleAttrs = style.trim().toLowerCase().split(";");
for (String attr : styleAttrs) {
attr = attr.trim();
if (attr.indexOf(matchAttr) == 0) {
String[] split = attr.split(":");
if (split.length != 2) {
continue;
}
return split[1].trim();
}
}
return null;
}
@Override
public void endHandleTag(Editable originEditable) {
Integer index = 0;
if (!spanStartIndexStack.empty()) {
index = spanStartIndexStack.pop();
if (index == null) {
index = 0;
}
}
FontSize fontSizeSpan = getLastSpanFromEdit(index, originEditable, FontSize.class);
if (fontSizeSpan != null) {
tagSpans(originEditable, fontSizeSpan, new AbsoluteSizeSpan(fontSizeSpan.fontSize, true));
}
ForegroundColor foregroundColorSpan = getLastSpanFromEdit(index, originEditable, ForegroundColor.class);
if (foregroundColorSpan != null) {
tagSpans(originEditable, foregroundColorSpan, new ForegroundColorSpan(foregroundColorSpan.foregroundColor));
}
BackgroundColor backgroundColorSpan = getLastSpanFromEdit(index, originEditable, BackgroundColor.class);
if (backgroundColorSpan != null) {
tagSpans(originEditable, backgroundColorSpan, new BackgroundColorSpan(backgroundColorSpan.backgroundColor));
}
Bold boldSpans = getLastSpanFromEdit(index, originEditable, Bold.class);
if (boldSpans != null) {
tagSpans(originEditable, boldSpans, new CustomFontBoldSpan());
}
}
@Override
public void finishHandleTag(Editable originEditable) {
while (!stashSpanStyleStack.empty()) {
final StashedSpanStyle stashedSpanStyle = stashSpanStyleStack.pop();
if (stashedSpanStyle == null) {
continue;
}
originEditable.setSpan(stashedSpanStyle.span, stashedSpanStyle.start, stashedSpanStyle.end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
}
/**
* 标记span样式的起点位置
*
* @param editable
* @param mark
*/
private void setSpanStartIndex(Editable editable, Object mark) {
// startHandle阶段 setSpan只做标记位置作用不实现具体效果
int length = editable.length();
editable.setSpan(mark, length, length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
}
/**
* 根据起点终点保存span样式
*
* @param editable
* @param mark
* @param spans
*/
private void tagSpans(Editable editable, Object mark, Object... spans) {
int start = editable.getSpanStart(mark);
editable.removeSpan(mark);
int end = editable.length();
if (start != end) {
for (Object span : spans) {
stashSpanStyleStack.push(new StashedSpanStyle(span, start, end));
}
}
}
private static class StashedSpanStyle {
Object span;
int start;
int end;
public StashedSpanStyle(Object span, int start, int end) {
this.span = span;
this.start = start;
this.end = end;
}
}
private static class Bold {
}
private static class FontSize {
int fontSize;
public FontSize(int fontSize) {
this.fontSize = fontSize;
}
}
private static class BackgroundColor {
int backgroundColor;
public BackgroundColor(int backgroundColor) {
this.backgroundColor = backgroundColor;
}
}
private static class ForegroundColor {
int foregroundColor;
public ForegroundColor(int foregroundColor) {
this.foregroundColor = foregroundColor;
}
}
private static class CustomFontBoldSpan extends StyleSpan {
public CustomFontBoldSpan() {
super(Typeface.BOLD);
}
public CustomFontBoldSpan(@NonNull Parcel src) {
super(src);
}
@Override
public void updateDrawState(TextPaint tp) {
tp.setStrokeWidth(1.0F);
tp.setStyle(Paint.Style.FILL_AND_STROKE);
}
}
}
CustomHtmlTagHandler作为Html.fromHtml的自定义标签的入口,负责分发匹配到的标签。
public class CustomHtmlTagHandler implements Html.TagHandler, ContentHandler {
private static final String TAG = "CustomHtmlTagHandler";
private final String CUSTOM_TAG = "custom";
private XMLReader originXmlReader;
private ContentHandler originContentHandler;
private Editable originEditable;
private int count;
private final ArraySet<String> ORIGIN_TAGS = new ArraySet<>(Arrays.asList(
"br", "p", "ul", "li", "div", "span", "strong", "b", "em", "cite", "dfn", "i",
"big", "small", "font", "blockquote", "tt", "a", "u", "del", "s", "strike",
"sup", "sub", "h1", "h2", "h3", "h4", "h5", "h6", "img"
));
private ArrayMap<String, BaseHtmlTag> tagMaps = new ArrayMap<>();
public void registerTag(String tag, BaseHtmlTag htmlTag) {
tagMaps.put(tag.toLowerCase(), htmlTag);
}
public BaseHtmlTag removeTag(String tag) {
tag = tag.toLowerCase();
if (tagMaps.containsKey(tag)) {
return tagMaps.remove(tag);
}
return null;
}
@Override
public void handleTag(boolean opening, String tag, Editable output, XMLReader xmlReader) {
if (opening) {
startHandleTag(tag.toLowerCase(), output, xmlReader);
} else {
endHandleTag(tag.toLowerCase(), output, xmlReader);
}
}
private void startHandleTag(String tag, Editable output, XMLReader xmlReader) {
switch (tag) {
case CUSTOM_TAG:
if (originContentHandler == null) {
originContentHandler = xmlReader.getContentHandler();
originXmlReader = xmlReader;
xmlReader.setContentHandler(this);
originEditable = output;
}
count++;
break;
default:
}
}
private void endHandleTag(String tag, Editable output, XMLReader xmlReader) {
switch (tag) {
case CUSTOM_TAG:
count--;
if (count == 0) {
for (String key : tagMaps.keySet()) {
tagMaps.get(key).finishHandleTag(originEditable);
}
originXmlReader.setContentHandler(originContentHandler);
originXmlReader = null;
originContentHandler = null;
originEditable = null;
}
break;
default:
}
}
@Override
public void startElement(String uri, String localName, String qName, Attributes atts) throws SAXException {
//忽略大小写
localName = localName.toLowerCase();
if (localName.equalsIgnoreCase(CUSTOM_TAG)) {
handleTag(true, localName, originEditable, originXmlReader);
} else if (canHandleTag(localName)) {
tagMaps.get(localName).startHandleTag(originEditable, atts);
} else if (ORIGIN_TAGS.contains(localName)) {
originContentHandler.startElement(uri, localName, qName, atts);
} else {
Log.e(TAG, "startElement: <" + localName + ">标签不可被解析");
}
}
@Override
public void endElement(String uri, String localName, String qName) throws SAXException {
//忽略大小写
localName = localName.toLowerCase();
if (localName.equalsIgnoreCase(CUSTOM_TAG)) {
handleTag(false, localName, originEditable, originXmlReader);
} else if (canHandleTag(localName)) {
tagMaps.get(localName).endHandleTag(originEditable);
} else if (ORIGIN_TAGS.contains(localName)) {
originContentHandler.endElement(uri, localName, qName);
} else {
Log.e(TAG, "endElement: </" + localName + ">标签不可被解析");
}
}
public boolean canHandleTag(String tagName) {
if (!tagMaps.containsKey(tagName)) {
return false;
}
BaseHtmlTag baseHtmlTag = tagMaps.get(tagName);
return baseHtmlTag != null;
}
@Override
public void characters(char[] ch, int start, int length) throws SAXException {
originContentHandler.characters(ch, start, length);
}
@Override
public void ignorableWhitespace(char[] ch, int start, int length) throws SAXException {
originContentHandler.ignorableWhitespace(ch, start, length);
}
@Override
public void processingInstruction(String target, String data) throws SAXException {
originContentHandler.processingInstruction(target, data);
}
@Override
public void skippedEntity(String name) throws SAXException {
originContentHandler.skippedEntity(name);
}
@Override
public void setDocumentLocator(Locator locator) {
originContentHandler.setDocumentLocator(locator);
}
@Override
public void startDocument() throws SAXException {
originContentHandler.startDocument();
}
@Override
public void endDocument() throws SAXException {
originContentHandler.endDocument();
}
@Override
public void startPrefixMapping(String prefix, String uri) throws SAXException {
originContentHandler.startPrefixMapping(prefix, uri);
}
@Override
public void endPrefixMapping(String prefix) throws SAXException {
originContentHandler.endPrefixMapping(prefix);
}
}
具体使用
将我们匹配的自定义标签注册进TagHandler中保存的Map。我们这次直接重写的span标签,当然你任意随便命名也没有关系。
public static CharSequence fromHtml(String htmlStr) {
if (TextUtils.isEmpty(htmlStr)) {
return "";
}
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
CustomHtmlTagHandler tagHandler = new CustomHtmlTagHandler();
tagHandler.registerTag(CustomSpanTag.SPAN, new CustomSpanTag());
return Html.fromHtml(htmlStr, Html.FROM_HTML_MODE_LEGACY, null, tagHandler);
} else {
return Html.fromHtml(htmlStr);
}
} catch (Exception ignore) {
}
return htmlStr;
}
简单分析
Html.fromHtml(String source, int flags, ImageGetter imageGetter,TagHandler tagHandler)
return converter.convert();
public Spanned convert() {
//xmlReader设置ContentHandler
mReader.setContentHandler(this);
try {
//xmlReader解析文本
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);
}
...
xmlReader.parse触发startElement,调用handleStartTag
private void handleStartTag(String tag, Attributes attributes) {
if (tag.equalsIgnoreCase("br")) {
// We don't need to handle this. TagSoup will ensure that there's a </br> for each <br>
// so we can safely emit the linebreaks when we handle the close tag.
} else if (tag.equalsIgnoreCase("p")) {
startBlockElement(mSpannableStringBuilder, attributes, getMarginParagraph());
startCssStyle(mSpannableStringBuilder, attributes);
} else if (tag.equalsIgnoreCase("ul")) {
startBlockElement(mSpannableStringBuilder, attributes, getMarginList());
} else if (tag.equalsIgnoreCase("li")) {
startLi(mSpannableStringBuilder, attributes);
} else if (tag.equalsIgnoreCase("div")) {
startBlockElement(mSpannableStringBuilder, attributes, getMarginDiv());
...
} else if (tag.equalsIgnoreCase("img")) {
startImg(mSpannableStringBuilder, attributes, mImageGetter);
} else if (mTagHandler != null) {
mTagHandler.handleTag(true, tag, mSpannableStringBuilder, mReader);
}
}
可以得知Android支持的Html标签种类,并且是要在所有标签都匹配不上的前提才会进入使用自定义TagHandler的判断分支,所以创建一个新的自定义标签是有必要的,无法直接重写现有标签,因为那样无法进入自定义标签处理。
CustomHtmlTagHandler startHandleTag方法匹配对应的标签,首先提取出原有Html自身的ContentHandler作为私有变量,之后改变xmlReader的contentHandler为CustomHtmlTagHandler,之后标签的匹配就由CustomHtmlTagHandler#startElement触发,如果标签并不是Map保存的标签,则调用原有的ContentHandler处理,否则进入CustomSpanTag处理。CustomSpanTag存在两个变量spanStartIndexStack、stashSpanStyleStack分别保存span的起始位置和span的起点终点的对象。CustomSpanTag#startHandleTag方法记录每个头标签在整段文案中的位置保存到spanStartIndexStack以及Editable对象添加span标记。由于使用的是栈对象保存的头标签起始位置,所以每次endHandleTag都可以匹配到当前尾标签对应的头标签,记录下当前Span的起点和终点位置。最后等到匹配上</ custom>自定义标签,触发finishHandleTag,将所有入栈的span对象依次出栈将样式设置到Editable对象上,实现功能。不直接在endHandleTag处理的原因是Html标签的嵌套特点是越里层的标签展示的优先级越高,如果在endHandleTag直接设置样式将导致外层的标签样式覆盖里层的标签样式。
最后用几个常见的html标签嵌套样式测试功能,均与html的展示样式一致。
public class MainActivity extends AppCompatActivity {
private TextView tv;
private Button btn;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
tv = findViewById(R.id.tv);
String txt = "<custom><span style=\"color:1111;font-size:50px;background-color:#FFFF00\">测试内容<span style=\"color:#AAFFAF;font-size:25px\"><u>测试内容</u></span></span><font color='#CCFFCC'>网上搜</font></custom>";
tv.setText(HtmlUtil.fromHtml(txt, this));
btn = findViewById(R.id.btn);
String txt2 = "<custom><p>话题故事内容,巴坎布副对戒多家分店。反馈到洛杉矶发动机弗兰克多家分" +
"店发的,都快疯了接口及打开了辅导费的,f'k'd'j'l'k'f'j'd'f'd</p><p><span style=\"color: " +
"#ff7e00; font-size: 20px;\">话题故事内容,巴坎布副对戒多家分店。反馈到洛杉矶发动机弗兰克多家分店发" +
"的,都快疯了接口及打开了辅导费的,飞快的将离开房间大幅度。</span><span style=\"color: #27ad9a; font" +
"-size: 16px;font-weight:bold\">话题故事内容,巴坎布副对戒多家分店。反馈到洛杉矶发动机弗兰克多家分店发的," +
"都快疯了接口及打开了辅导费的,飞快的将离开房间大幅度。</span><br><span style=\"color: #333333; font-size" +
": 25px;\">话题故事内容,巴坎布副对戒多家分店。反馈到洛杉矶发动机弗兰克多家分店发的,都快疯了接口及打开了辅导费的,飞快" +
"的将离开房间大幅度。</span><br></p></custom>";
String txt3 = "<custom><span>普通样式文本</span><br><span style=\"color: #ff7e00; font-size: 20px;\">黄色文字,字号偏大。</span>" +
"<span style=\"color: #27ad9a; font-size: 20px;font-weight:bold\">" +
"后面的绿色文字,字体加粗的。</span><br><del>中间的删除线文本</del></custom>";
String txt4 = "<custom><span style=\"color: #E9B159; font-size: 12px;\">AA<span style=\"font-size: 25px;\">BB<span style=\"color: #FF3359; font-size: 12px;\">AA<span style=\"font-size: 25px;\">BB</span>DD</span></span>CC</span></custom>";
String txt5 = "<custom><span style=\"color: #E9B159; font-size: 20px;\">AA<span style=\"font-size: 25px;\">BB</span>CC</span></custom>";
String txt6 = "<custom><span style=\"color: #E9B159; font-size: 16px;\">AA</span><span style=\"font-size: 20px;\">BB</span></custom>";
String txt7 = "<span style=\"color: #E9B159; background-color: #FF00FF;\">AA</span><span style=\"background-color: #FF0000;\">BB</span>";
btn.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
tv.setText(HtmlUtil.fromHtml(txt7));
}
});
}
}