前言
要实现上面效果怎么做?
Android 中 TextView 可以实现简单的 HTML 解析,将 Html 文本封装为 Spannable 数据实现图文混排等富文本效果,但是同样问题很多。
- 1、Android系统中提供的解析能力不够强,提供的CSS样式支持不足,对于css 属性的解析和支持很弱
- 2、不支持多个多种css 样式同时解析
- 3、SDK 中提供的 Html.TagHandler 无法获取到标签属性
- 4、无法支持自定义Html 标签
- 5、无法支持自定义CSS属性
基于以上缺陷,如果我们想在TextView中支持更丰富的样式,相对来说还不如用SpannableString方便,但是作为Html,他的通用性是目前来说比较高的,就算markdown最终也会转为css + html样式。其实对比浏览器中博客页面和手机app中展示的博客页面,你就会发现手机端支持很弱,根本没有支持主题,甚至还不如使用WebView的效果。
思路
- 方案1: 自定义一套 HTML 解析器,其实很简单,复制一份 android.text.Html,替换其中 SDK 隐藏的 XmlReader 即可
- 方案2:移花接木,通过 Html.TagHandler 夺取解析流程处理器,然后获得拦截解析 html标签 的能力,在拦截到标签之后自行解析。
这两种方案实质上都是可行的,第一种的话要实现自己的 SaxParser 解析,但工作量不小,因此这里我们主要提供方案二的实现方式,同时也能和原有的逻辑相互切换。
本篇内容其实发表于2019年3月,但是展现量一直不高且之前的博客网站隔三差五出幺蛾子,恰好想换一个博客平台,趁此搬过来。另外强调时间还有个原因是因为有人采用了一样的方法,连计数、tag切换逻辑都一样,但我比他更早,免得到时候引起争议。
最终方案:移花接木
之所以可以移花接木,是因为 TagHandler 会被作为 Html 中标签解析的最后一个流程语句,当遇到自定义的或者 Html 类无法解析的标签,标签调用 TagHandler 的 handleTag 方法会被回调,同时可以获得 TagName,Editable,XmlReader,然后我们便可移花接木。
- 为什么可以移花接木?
- 答案: 在android.text.html类中,只有无法解析的标签才走TagHandler逻辑,因此我们给的起始标签必须不让他解析,下面过程中你就能体会到。
我们移花接木的核心入口是TagHandler,如果TagHandler#handleTag的第一个参数是true,表示开始解析任意标签,false为结束解析任意标签,当然,这里的开始是对所有标签都有效。
java
public static interface TagHandler {
public void handleTag(boolean opening, String tag,
Editable output, XMLReader xmlReader);
}
我们紧接着封装一下
java
@Override
public void handleTag(boolean opening, String tag, Editable output, XMLReader xmlReader) {
if(opening){
startHandleTag(tag,output,xmlReader);
}else{
endHandleTag(tag,output,xmlReader);
}
}
我们前面说过,移花接木必须是html的标签无法被解析
java
handleStartTag()
恰好html标签也无法解析,因此我这里使用html,当然也有人是这么做的。
另一方面,在这里我们知道,html是树状结构,因此在树的遍历过程中什么div、span、body、head都会走这样的逻辑,但是平时我们使用的Html.fromHtml()的时候,一般不会加上<html>标签在文本开始和结尾处,基于这个习惯,为了方便切换系统定义的渲染方式,我们这里加上html标签
java
private final String H5_TAG = "html"; //自定义标签,该标签无法在原Html类中解析
当前仅当,解析到html的时候进行获取解析流程处理器,那什么是解析流程控制器呢?其实主要是4个工具 xmlReader和ContentHandler,当然同时我们也要获取,
但我们添加计数,这个原因主要是防止html出现多层嵌套的问题,导致提前归还解析器控制器
html
<html><span> <html>第二层</html> </span></html>
核心点,下面是的夺取解析器处理器核心逻辑
java
private void startHandleTag( String tag, Editable output, XMLReader xmlReader) {
if (tag.equalsIgnoreCase(H5_TAG)){
if(orginalContentHandler==null) {
orginalContentHandler = xmlReader.getContentHandler();
this.originalXmlReader = xmlReader; //获取XmlReader
this.originalXmlReader.setContentHandler(this);//获取控制权,让本类监听解析流程
this.originlaEditableText = output; //获取到SpannableStringBuilder
}
count++;
}
}
private void endHandleTag( String tag, Editable output, XMLReader xmlReader) {
if(tag.equalsIgnoreCase(tag)){
count--;
if(count==0 ){
this.originalXmlReader.setContentHandler(this.orginalContentHandler);
//将原始的handler交还
this.originalXmlReader = null;
this.originlaEditableText = null;
this.orginalContentHandler = null;
//还原控制权
}
}
}
接手控制器之后,我们当然是需要解析的,但是解析需要我们坚挺ContentHandler,具体实现如下 首先对标签进行管理
js
//自定义解析器集合
private final Map<String,HtmlTag> tagHandlerMap;
进行拦截解析
java
@Override
public void startElement(String uri, String localName, String qName, Attributes atts) throws SAXException {
if (localName.equalsIgnoreCase(H5_TAG)){
handleTag(true,localName,this.originlaEditableText,this.originalXmlReader);
}else if(canHandleTag(localName)){ //拦截,判断是否可以解析该标签
final HtmlTag htmlTag = tagHandlerMap.get(localName); //读取自定义解析器开始解析
htmlTag.startHandleTag(this.originlaEditableText,atts);
}else if(orginalTags.contains(localName)){ //无法解析的优先让原Html类解析
this.orginalContentHandler.startElement(uri,localName,qName,atts);
}else{
Log.e(LOG_TAG,"无法解析的标签<"+localName+">");
}
}
private boolean canHandleTag(String tagName) {
if(!tagHandlerMap.containsKey(tagName)){
return false;
}
final HtmlTag htmlTag = tagHandlerMap.get(tagName);
return htmlTag!=null;
}
@Override
public void endElement(String uri, String localName, String qName) throws SAXException {
if (localName.equalsIgnoreCase(H5_TAG)){
handleTag(false,localName,this.originlaEditableText,this.originalXmlReader);
}else if(canHandleTag(localName)){
final HtmlTag htmlTag = tagHandlerMap.get(localName); //读取自定义解析器结束解析
htmlTag.endHandleTag(this.originlaEditableText);
}else if(orginalTags.contains(localName)){
this.orginalContentHandler.endElement(uri,localName,qName);
}else{
Log.e(LOG_TAG,"无法解析的标签</"+localName+">");
}
}
支持自定义标签
其实支持html样式最好还是对标签做处理,单纯的修改css还不如继承父类,好处是有些css样式是可以共用的,不过前提是。
但是在实现代码前,最好研究下Html对标签的标记和提取方法,方便我们后续扩展,下面方法参考android.text.Html类实现。
什么是标记?
在我们创建SpannbleString的时候,我们会对Text段加一些标记,当然标记是可以随便定义的,即便你把它标记成String类型或者Activity类型也是可以的,重要是在渲染逻辑中提取出标记
怎么渲染
这个得研究SpannbleString或者 android.text.Html类,主要是将标记转为TextView能渲染的各种Span,如BackgroundColorSpan和ForegroundSpan等。
java
//开始解析,主要负责css参数解析和标签标记
public abstract void startHandleTag(Editable text, Attributes attributes);
//结束解析 负责渲染
public abstract void endHandleTag(Editable text); //结束解析
下面是Html标签的基类,继承该类即可实现你自己的标签和css解析逻辑
java
public abstract class HtmlTag {
private Context context;
public HtmlTag(Context context) {
this.context = context;
}
public Context getContext() {
return context;
}
private static final Map<String, Integer> sColorNameMap;
static {
sColorNameMap = new ArrayMap<String, Integer>();
sColorNameMap.put("black", Color.BLACK);
sColorNameMap.put("darkgray", Color.DKGRAY);
sColorNameMap.put("gray", Color.GRAY);
sColorNameMap.put("lightgray", Color.LTGRAY);
sColorNameMap.put("white", Color.WHITE);
sColorNameMap.put("red", Color.RED);
sColorNameMap.put("green", Color.GREEN);
sColorNameMap.put("blue", Color.BLUE);
sColorNameMap.put("yellow", Color.YELLOW);
sColorNameMap.put("cyan", Color.CYAN);
sColorNameMap.put("magenta", Color.MAGENTA);
sColorNameMap.put("aqua", 0xFF00FFFF);
sColorNameMap.put("fuchsia", 0xFFFF00FF);
sColorNameMap.put("darkgrey", Color.DKGRAY);
sColorNameMap.put("grey", Color.GRAY);
sColorNameMap.put("lightgrey", Color.LTGRAY);
sColorNameMap.put("lime", 0xFF00FF00);
sColorNameMap.put("maroon", 0xFF800000);
sColorNameMap.put("navy", 0xFF000080);
sColorNameMap.put("olive", 0xFF808000);
sColorNameMap.put("purple", 0xFF800080);
sColorNameMap.put("silver", 0xFFC0C0C0);
sColorNameMap.put("teal", 0xFF008080);
sColorNameMap.put("white", Color.WHITE);
sColorNameMap.put("transparent", Color.TRANSPARENT);
}
@ColorInt
public static int getHtmlColor(String colorString){
if(sColorNameMap.containsKey(colorString.toLowerCase())){
Integer colorInt = sColorNameMap.get(colorString);
if(colorInt!=null) return colorInt;
}
return parseHtmlColor(colorString.toLowerCase());
}
@ColorInt
public static int parseHtmlColor( String colorString) {
if (colorString.charAt(0) == '#') {
if(colorString.length()==4){
StringBuilder sb = new StringBuilder("#");
for (int i=1;i<colorString.length();i++){
char c = colorString.charAt(i);
sb.append(c).append(c);
}
colorString = sb.toString();
}
long color = Long.parseLong(colorString.substring(1), 16);
if (colorString.length() == 7) {
// Set the alpha value
color |= 0x00000000ff000000;
} else if (colorString.length() == 9) {
int alpha = Integer.parseInt(colorString.substring(1,3),16) ;
int red = Integer.parseInt(colorString.substring(3,5),16);
int green = Integer.parseInt(colorString.substring(5,7),16);
int blue = Integer.parseInt(colorString.substring(7,8),16);
color = Color.argb(alpha,red,green,blue);
}else{
throw new IllegalArgumentException("Unknown color");
}
return (int)color;
}
else if(colorString.startsWith("rgb(") || colorString.startsWith("rgba(") && colorString.endsWith(")"))
{
colorString = colorString.substring(colorString.indexOf("("),colorString.indexOf(")"));
colorString = colorString.replaceAll(" ","");
String[] colorArray = colorString.split(",");
if(colorArray.length==3){
return Color.argb(255,Integer.parseInt(colorArray[0]),Integer.parseInt(colorArray[1]),Integer.parseInt(colorArray[2]));
}
else if (colorArray.length==4){
return Color.argb(Integer.parseInt(colorArray[3]),Integer.parseInt(colorArray[0]),Integer.parseInt(colorArray[1]),Integer.parseInt(colorArray[2]));
}
}
throw new IllegalArgumentException("Unknown color");
}
//负责提取标记
public static <T> T getLast(Spanned text, Class<T> kind) {
T[] objs = text.getSpans(0, text.length(), kind);
if (objs.length == 0) {
return null;
} else {
return objs[objs.length - 1];
}
}
//开始解析,主要负责css参数解析和标签标记
public abstract void startHandleTag(Editable text, Attributes attributes);
//结束解析 负责渲染
public abstract void endHandleTag(Editable text); //结束解析
}
下面我们以实现<span>标签为例,这样我们就重新定义了<span>标签,当然名字不重要,重要的是你可以随便写,如标签,BaobaoSpan
首先定义标记
java
public static class Font{ //定义标记
int textSize;
int textDecordation;
int fontWeidght;
public Font( int textSize,int textDecordation,int fontWeidght) {
this.textSize = textSize;
this.textDecordation = textDecordation;
this.fontWeidght = fontWeidght;
}
}
public static class Background{ //定义标记
int color;
public Background(int color) {
this.color = color;
}
}
定义Span
当然Span有很多种,我们可以选择系统中的,也可以自己定义,我这里为了让FontSpan更强到,自定义了一个新的
java
public class TextFontSpan extends AbsoluteSizeSpan {
public static final int FontWidget_NORMAL= 400;
public static final int FontWidget_BOLD = 750;
public static final int TextDecoration_NONE=0;
public static final int TextDecoration_UNDERLINE=1;
public static final int TextDecoration_LINE_THROUGH=2;
public static final int TextDecoration_OVERLINE=3;
private int fontWidget = -1;
private int textDecoration = -1;
private int mSize = -1;
public TextFontSpan(int size ,int textDecoration,int fontWidget) {
this(size,false);
this.mSize = size;
this.fontWidget = fontWidget;
this.textDecoration = textDecoration;
//这里我们以px作为单位,方便统一调用
}
/**
* 保持构造方法无法被外部调用
* @param size
* @param dip
*/
protected TextFontSpan(int size, boolean dip) {
super(size, dip);
}
public TextFontSpan(Parcel src) {
super(src);
fontWidget = src.readInt();
textDecoration = src.readInt();
mSize = src.readInt();
}
@Override
public void writeToParcel(Parcel dest, int flags) {
super.writeToParcel(dest, flags);
dest.writeInt(fontWidget);
dest.writeInt(textDecoration);
dest.writeInt(mSize);
}
@Override
public void updateDrawState(TextPaint ds) {
if(this.mSize>=0){
super.updateDrawState(ds);
}
if(fontWidget==FontWidget_BOLD) {
ds.setFakeBoldText(true);
}else if(fontWidget==FontWidget_NORMAL){
ds.setFakeBoldText(false);
}
if(textDecoration==TextDecoration_NONE) {
ds.setStrikeThruText(false);
ds.setUnderlineText(false);
}else if(textDecoration==TextDecoration_LINE_THROUGH){
ds.setStrikeThruText(true);
ds.setUnderlineText(false);
}else if(textDecoration==TextDecoration_UNDERLINE){
ds.setStrikeThruText(false);
ds.setUnderlineText(true);
}
}
@Override
public void updateMeasureState(TextPaint ds) {
if(this.mSize>=0){
super.updateMeasureState(ds);
}
if(fontWidget==FontWidget_BOLD) {
ds.setFakeBoldText(true);
}else if(fontWidget==FontWidget_NORMAL){
ds.setFakeBoldText(false);
}
if(textDecoration==TextDecoration_NONE) {
ds.setStrikeThruText(false);
ds.setUnderlineText(false);
}else if(textDecoration==TextDecoration_LINE_THROUGH){
ds.setStrikeThruText(true);
ds.setUnderlineText(false);
}else if(textDecoration==TextDecoration_UNDERLINE){
ds.setStrikeThruText(false);
ds.setUnderlineText(true);
}
}
}
完整的span标签逻辑
java
public class SpanTag extends HtmlTag {
public SpanTag(Context context) {
super(context);
}
private int getHtmlSize(String fontSize) {
fontSize = fontSize.toLowerCase();
if(fontSize.endsWith("px")){
return (int) Double.parseDouble(fontSize.substring(0,fontSize.indexOf("px")));
}else if(fontSize.endsWith("sp") ){
float sp = (float) Double.parseDouble(fontSize.substring(0,fontSize.indexOf("sp")));
return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP,sp,getContext().getResources().getDisplayMetrics());
}else if(TextUtils.isDigitsOnly(fontSize)){ //如果不带单位,默认按照sp处理
float sp = (float) Double.parseDouble(fontSize);
return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP,sp,getContext().getResources().getDisplayMetrics());
}
return -1;
}
private static String getTextColorPattern(String style) {
String cssName = "text-color";
String cssVal = getHtmlCssValue(style, cssName);
if(TextUtils.isEmpty(cssVal)){
cssName = "color";
cssVal = getHtmlCssValue(style, cssName);
}
return cssVal;
}
@Nullable
private static String getHtmlCssValue(String style, String cssName) {
if(TextUtils.isEmpty(style)) return null;
final String[] keyValueSet = style.toLowerCase().split(";");
if(keyValueSet==null) return null;
for (int i=0;i<keyValueSet.length;i++){
final String match = keyValueSet[i].replaceAll(" ","").toLowerCase();
if(match.indexOf(cssName)==0){
final String[] parts = match.split(":");
if(parts==null || parts.length!=2) continue;
return parts[1];
}
}
return null;
}
private static String getBackgroundColorPattern(String style) {
String cssName = "background-color";
String cssVal = getHtmlCssValue(style, cssName);
if(TextUtils.isEmpty(cssVal)){
cssName = "bakground";
cssVal = getHtmlCssValue(style, cssName);
}
return cssVal;
}
private static String getTextFontSizePattern(String style) {
String cssName = "font-size";
String cssVal = getHtmlCssValue(style, cssName);
if(TextUtils.isEmpty(cssVal)){
cssName = "text-size";
cssVal = getHtmlCssValue(style, cssName);
}
return cssVal;
}
private static String getTextDecorationPattern(String style) {
String cssName = "text-decoration";
String cssVal = getHtmlCssValue(style, cssName);
return cssVal;
}
private static String getTextFontPattern(String style) {
String cssName = "font-weight";
String cssVal = getHtmlCssValue(style, cssName);
return cssVal;
}
public static class Font{ //定义标记
int textSize;
int textDecordation;
int fontWeidght;
public Font( int textSize,int textDecordation,int fontWeidght) {
this.textSize = textSize;
this.textDecordation = textDecordation;
this.fontWeidght = fontWeidght;
}
}
public static class Background{ //定义标记
int color;
public Background(int color) {
this.color = color;
}
}
@Override
public void startHandleTag(Editable text, Attributes attributes) {
String style = attributes.getValue("", "style");
if(TextUtils.isEmpty(style)) return;
String textColorPattern = getTextColorPattern(style);
if (!TextUtils.isEmpty(textColorPattern)) {
int c = getHtmlColor(textColorPattern);
c = c | 0xFF000000;
start(text,new ForegroundColorSpan(c));
}
startMarkTextFont(text,style);
String backgroundColorPattern = getBackgroundColorPattern(style);
if (!TextUtils.isEmpty(backgroundColorPattern)) {
int c = getHtmlColor(backgroundColorPattern);
c = c | 0xFF000000;
//注意,第二个参数可以为任意Object类型,这里起到标记的作用
start(text,new Background(c));
}
}
private void startMarkTextFont(Editable text ,String style) {
String fontSize = getTextFontSizePattern(style);
String textDecoration = getTextDecorationPattern(style);
String fontWidget = getTextFontPattern(style);
int textSize = -1;
if(TextUtils.isEmpty(fontSize)){
if(!TextUtils.isEmpty(fontSize)){
textSize = getHtmlSize(fontSize);
}
}
int textDecorationVal = -1;
if(!TextUtils.isEmpty(textDecoration)){
if(textDecoration.equals("underline")) {
textDecorationVal = TextFontSpan.TextDecoration_UNDERLINE;
}else if(textDecoration.equals("line-through")){
textDecorationVal = TextFontSpan.TextDecoration_LINE_THROUGH;
}
else if(textDecoration.equals("overline")){
textDecorationVal = TextFontSpan.TextDecoration_OVERLINE;//暂不支持
} else if(textDecoration.equals("none")){
textDecorationVal = TextFontSpan.TextDecoration_NONE;
}
}
int fontWeidgtVal = -1;
if(!TextUtils.isEmpty(fontWidget)){
if(textDecoration.equals("normal")) {
fontWeidgtVal = TextFontSpan.FontWidget_NORMAL;
}else if(textDecoration.equals("bold")){
fontWeidgtVal = TextFontSpan.FontWidget_BOLD;
}
}
start(text,new Font(textSize,textDecorationVal,fontWeidgtVal));
}
@Override
public void endHandleTag(Editable text){
Background b = getLast(text, Background.class); //读取出最后标记类型
if(b!=null){
end(text,Background.class,new BackgroundColorSpan(b.color)); //设置为Android可以解析的24种ParcelableSpan基本分类,当然也可以自己定义,但需要集成原有的分类
}
final ForegroundColorSpan fc = getLast(text, ForegroundColorSpan.class);
if(fc!=null){
end(text,ForegroundColorSpan.class,new ForegroundColorSpan(fc.getForegroundColor()));
}
Font f = getLast(text, Font.class);
if (f != null) {
end(text,Font.class,new TextFontSpan(f.textSize,f.textDecordation,f.fontWeidght)); //使用自定义的
}
}
private static void start(Editable text, Object mark) {
int len = text.length();
text.setSpan(mark, len, len, Spannable.SPAN_INCLUSIVE_EXCLUSIVE); //添加标记在最后一位,注意开始位置和结束位置
}
@SuppressWarnings("unchecked")
private static void end(Editable text, Class kind, Object repl) {
Object obj = getLast(text, kind); //读取kind类型
if (obj != null) {
setSpanFromMark(text, obj, repl);
}
}
private static void setSpanFromMark(Spannable text, Object mark, Object... spans) {
int where = text.getSpanStart(mark);
text.removeSpan(mark);
//移除原有标记,因为原有标记不是默认的24种ParcelableSpan子类,因此无法渲染文本
int len = text.length();
if (where != len) {
for (Object span : spans) {
text.setSpan(span, where, len, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); //注意:开始位置和结束位置,因为SpannableStringBuilder的append添加字符方法导致len已经大于where了
}
}
}
}
用法
替换拦截标签,下面我们替换默认的span标签逻辑,当然你也可以注册成其他的标签,如 field
java
HtmlTagHandler htmlTagHandler = new HtmlTagHandler();
htmlTagHandler.registerTag("span",new SpanTag(targetFragment.getContext()));
htmlTagHandler.registerTag("filed",new SpanTag(targetFragment.getContext()));
然后写一段html,输入进去即可
java
String source = "<html>今天<span style='color:#FFE31335;font-size:16sp;background-color:white;'>星期三</span>,<span style='color:#fff;font-size:14sp;background-color:red;'>但是我还要加班</span><html>";
final Spanned spanned = Html.fromHtml(source, htmlTagHandler, htmlTagHandler);
textView.setText(spanned );
注意: <html> 标签必须加到要解析的文本段,否则 Android 系统仍然会走 Html 的解析流程。
总结
自定义Html标签,使得TextView具备更多更强的html解析能力,整个过程看似复杂,实际上了解了xml或者html解析过程,你就会对控制流更加熟悉。
源码
本篇不提供源码,因为已开源,从下面开源地址获取接口。 gitee.com/smartian_gi...