Android TextView加载Html自定义标签实现富文本效果

3,663 阅读4分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第22天,点击查看活动详情

Android实现TextView加载Html标签的逻辑

关于Android富文本的实现,之前的文章也有写到过几种方式。那么在国际化前提下的富文本实现,最好的方式是通过Html来指定实现富文本了。

由于英文,马来语,印地语它们的语法顺序都和中文不同,如果按Span的方法来指定索引变色来实现,几乎是不现实的。例如:

HR from Custom Company has viewed your resume.

Custom Company的人事专员查看了你的简历。

同样的话,我们需要把公司的字段变色方法,替换字体,如果用富文本的实现方法,我们要获取到当前系统语言,根据语言if else 来写不同的substring的方法去找索引替换Span。

其实我们用Html一样可以实现富文本的实现,我们Android的TextView的 Html.fromHtml 方法是可以解析部分标签的。

如果不支持的标签我们可以自定义实现,还可以自定义标签实现原本Html实现不了的效果。

一、单标签的实现

自定义字体的工具库

/**
 * 系统原生的TypefaceSpan只能使用原生的默认字体
 * 如果使用自定义的字体,通过这个来实现
 */
public class MyTypefaceSpan extends MetricAffectingSpan {

    private final Typeface typeface;

    public MyTypefaceSpan(final Typeface typeface) {
        this.typeface = typeface;
    }

    @Override
    public void updateDrawState(final TextPaint drawState) {
        apply(drawState);
    }

    @Override
    public void updateMeasureState(final TextPaint paint) {
        apply(paint);
    }

    private void apply(final Paint paint) {
        final Typeface oldTypeface = paint.getTypeface();
        final int oldStyle = oldTypeface != null ? oldTypeface.getStyle() : 0;
        int fakeStyle = oldStyle & ~typeface.getStyle();
        if ((fakeStyle & Typeface.BOLD) != 0) {
            paint.setFakeBoldText(true);
        }
        if ((fakeStyle & Typeface.ITALIC) != 0) {
            paint.setTextSkewX(-0.25f);
        }
        paint.setTypeface(typeface);
    }

}

自定义标签解析器的实现

/**
 * Html的TextView标签解释
 * <face></face>
 */
public class TypeFaceLabel implements Html.TagHandler {
    private Typeface typeface;
    private int startIndex = 0;
    private int stopIndex = 0;

    public TypeFaceLabel(Typeface typeface) {
        this.typeface = typeface;
    }

    @Override
    public void handleTag(boolean opening, String tag, Editable output, XMLReader xmlReader) {
        if (tag.toLowerCase().equals("face")) {
            if (opening) {
                startIndex = output.length();
            } else {
                stopIndex = output.length();
                //使用的是自定义的字体来实现
                output.setSpan(new MyTypefaceSpan(typeface), startIndex, stopIndex, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
            }
        }
    }

}

实现的方式:

  String content = "<font color=\"#000000\">HR from </font>" +
                    "<face><font color=\"#0689FB\">" + item.employer_name + "</font></face>" +
                    "<font color=\"#000000\"> has viewed your resume.</font>";

    if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) {
          tv_resume_log_content.setText(Html.fromHtml(content, Html.FROM_HTML_MODE_LEGACY, null, new TypeFaceLabel(TypefaceUtil.getSFSemobold(mContext))));
        } else {
          tv_resume_log_content.setText(Html.fromHtml(content, null, new TypeFaceLabel(TypefaceUtil.getSFSemobold(mContext))));
    }

效果:

二、多标签的实现

这样确实是可以实现一个简单的Html的标签解析了,但是这种方式只能解析一个自定义的Tag,如果我们想支持多个自定义Tag就会出现start end 索引的冲突问题,我们需要使用一个栈的数据结构来保存不同tag的索引。

工具类如下:


/**
 * 支持的标签为
 * <del>xxx</del>  中划线
 * <size value='16'>xxx</size>  自定义大小文本
 * <face>xxx</face>       自定义字体
 */
public class CustomerLableHandler implements Html.TagHandler {

    private Typeface typeface;
    private int imgRes;

    public CustomerLableHandler(Typeface typeface, int imgRes) {
        this.typeface = typeface;
        this.imgRes = imgRes;
    }

    /**
     * html 标签的开始下标,为了支持多个标签,使用栈管理开始下标
     */
    private Stack<Integer> startIndex;

    /**
     * html的标签的属性值
     */
    private Stack<String> propertyValue;

    @Override
    public void handleTag(boolean opening, String tag, Editable output, XMLReader xmlReader) {
        if (opening) {
            handlerStartTAG(tag, output, xmlReader);
        } else {
            handlerEndTAG(tag, output);
        }
    }

    /**
     * 处理开始的标签位
     */
    private void handlerStartTAG(String tag, Editable output, XMLReader xmlReader) {
        if (tag.equalsIgnoreCase("del")) {
            handlerStartDEL(output);
        } else if (tag.equalsIgnoreCase("size")) {
            handlerStartSIZE(output, xmlReader);
        }else if (tag.equalsIgnoreCase("face")){
            handleStartFACE(output);
        }else if (tag.equalsIgnoreCase("icon")){
            handleStartICON(output);
        }
    }

    /**
     * 处理结尾的标签位
     */
    private void handlerEndTAG(String tag, Editable output) {
        if (tag.equalsIgnoreCase("del")) {
            handlerEndDEL(output);
        } else if (tag.equalsIgnoreCase("size")) {
            handlerEndSIZE(output);
        }else if (tag.equalsIgnoreCase("face")){
            handleEndFACE(output);
        }else if (tag.equalsIgnoreCase("icon")){
            handleEndICON(output);
        }
    }

    // =======================  自定义Icon begin ↓ =========================

    private void handleStartICON(Editable output) {
        if (startIndex == null) {
            startIndex = new Stack<>();
        }
        startIndex.push(output.length());
    }

    private void handleEndICON(Editable output) {

        Drawable drawable = CommUtils.getDrawable(imgRes);
        drawable.setBounds(0, 0, drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight());
        MiddleIMarginImageSpan imageSpan = new MiddleIMarginImageSpan(drawable, 4, 0, 0);

        output.setSpan(imageSpan, startIndex.pop(), output.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
    }

    // =======================  自定义Icon end ↑ =========================



    // =======================  自定义字体 begin ↓ =========================

    private void handleStartFACE(Editable output) {
        if (startIndex == null) {
            startIndex = new Stack<>();
        }
        startIndex.push(output.length());
    }

    private void handleEndFACE(Editable output) {
        output.setSpan(new CustomTypefaceSpan(typeface), startIndex.pop(), output.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
    }

    // =======================  自定义字体 end ↑ =========================


    // =======================  中划线处理 begin ↓ =========================

    private void handlerStartDEL(Editable output) {
        if (startIndex == null) {
            startIndex = new Stack<>();
        }
        startIndex.push(output.length());
    }

    //中划线的Span
    private void handlerEndDEL(Editable output) {
        output.setSpan(new StrikethroughSpan(), startIndex.pop(), output.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
    }

    // =======================  中划线处理 end ↑ =========================

    // =======================  文本大小设置 begin ↓ =========================

    private void handlerStartSIZE(Editable output, XMLReader xmlReader) {
        if (startIndex == null) {
            startIndex = new Stack<>();
        }
        startIndex.push(output.length());

        if (propertyValue == null) {
            propertyValue = new Stack<>();
        }
        //获取自定义标签内部的属性值
        propertyValue.push(getProperty(xmlReader, "value"));
    }

    private void handlerEndSIZE(Editable output) {

        if (!propertyValue.isEmpty()) {
            try {
                int value = Integer.parseInt(propertyValue.pop());
                output.setSpan(new AbsoluteSizeSpan(CommUtils.dip2px(value)), startIndex.pop(), output.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
    // =======================  文本大小设置 end ↑ =========================

    /**
     * 利用反射获取html标签的属性值
     */
    private String getProperty(XMLReader xmlReader, String property) {
        try {
            Field elementField = xmlReader.getClass().getDeclaredField("theNewElement");
            elementField.setAccessible(true);
            Object element = elementField.get(xmlReader);
            Field attsField = element.getClass().getDeclaredField("theAtts");
            attsField.setAccessible(true);
            Object atts = attsField.get(element);
            Field dataField = atts.getClass().getDeclaredField("data");
            dataField.setAccessible(true);
            String[] data = (String[]) dataField.get(atts);
            Field lengthField = atts.getClass().getDeclaredField("length");
            lengthField.setAccessible(true);
            int len = (Integer) lengthField.get(atts);

            for (int i = 0; i < len; i++) {
                // 这边的property换成你自己的属性名就可以了
                if (property.equals(data[i * 5 + 1])) {
                    return data[i * 5 + 4];
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }

}

内部的ImageSpan是自行封装的可以居中,居下,设置margin的Span,实现如下:

public class MiddleIMarginImageSpan extends AlignMiddleImageSpan {

    private int mSpanMarginLeft = 0;
    private int mSpanMarginRight = 0;
    private int mOffsetY = 0;

    public MiddleIMarginImageSpan(Drawable d, int verticalAlignment, int marginLeft, int marginRight) {
        this(d, verticalAlignment, marginLeft, marginRight, 0);
    }

    public MiddleIMarginImageSpan(Drawable d, int verticalAlignment, int marginLeft, int marginRight, int offsetY) {
        super(d, verticalAlignment);
        mSpanMarginLeft = marginLeft;
        mSpanMarginRight = marginRight;
        mOffsetY = offsetY;
    }

    @Override
    public int getSize(Paint paint, CharSequence text, int start, int end, Paint.FontMetricsInt fm) {
        if (mSpanMarginLeft != 0 || mSpanMarginRight != 0) {
            super.getSize(paint, text, start, end, fm);
            Drawable d = getDrawable();
            return d.getIntrinsicWidth() + mSpanMarginLeft + mSpanMarginRight;
        } else {
            return super.getSize(paint, text, start, end, fm);
        }
    }

    @Override
    public void draw(Canvas canvas, CharSequence text, int start, int end,
                     float x, int top, int y, int bottom, Paint paint) {
        canvas.save();
        canvas.translate(0, mOffsetY);
        // marginRight不用专门处理,只靠getSize()中改变即可
        super.draw(canvas, text, start, end, x + mSpanMarginLeft, top, y, bottom, paint);
        canvas.restore();
    }
}
public class AlignMiddleImageSpan extends ImageSpan {

    public static final int ALIGN_MIDDLE = 4; // 默认垂直居中

    /**
     * 规定这个Span占几个字的宽度
     */
    private float mFontWidthMultiple = -1f;

    /**
     * 是否避免父类修改FontMetrics,如果为 false 则会走父类的逻辑, 会导致FontMetrics被更改
     */
    private boolean mAvoidSuperChangeFontMetrics = false;

    @SuppressWarnings("FieldCanBeLocal")
    private int mWidth;
    private Drawable mDrawable;
    private int mDrawableTintColorAttr;

    /**
     * @param d                 作为 span 的 Drawable
     * @param verticalAlignment 垂直对齐方式, 如果要垂直居中, 则使用 {@link #ALIGN_MIDDLE}
     */
    public AlignMiddleImageSpan(Drawable d, int verticalAlignment) {
        this(d, verticalAlignment, 0);
    }

    /**
     * @param d                 作为 span 的 Drawable
     * @param verticalAlignment 垂直对齐方式, 如果要垂直居中, 则使用 {@link #ALIGN_MIDDLE}
     * @param fontWidthMultiple 设置这个Span占几个中文字的宽度, 当该值 > 0 时, span 的宽度为该值*一个中文字的宽度; 当该值 <= 0 时, span 的宽度由 {@link #mAvoidSuperChangeFontMetrics} 决定
     */
    public AlignMiddleImageSpan(@NonNull Drawable d, int verticalAlignment, float fontWidthMultiple) {
        super(d.mutate(), verticalAlignment);
        mDrawable = getDrawable();
        if (fontWidthMultiple >= 0) {
            mFontWidthMultiple = fontWidthMultiple;
        }
    }

    @Override
    public int getSize(Paint paint, CharSequence text, int start, int end, Paint.FontMetricsInt fm) {
        if (mAvoidSuperChangeFontMetrics) {
            Drawable d = getDrawable();
            Rect rect = d.getBounds();
            mWidth = rect.right;
        } else {
            mWidth = super.getSize(paint, text, start, end, fm);
        }
        if (mFontWidthMultiple > 0) {
            mWidth = (int) (paint.measureText("子") * mFontWidthMultiple);
        }
        return mWidth;
    }

    @Override
    public void draw(Canvas canvas, CharSequence text, int start, int end,
                     float x, int top, int y, int bottom, Paint paint) {
        if (mVerticalAlignment == ALIGN_MIDDLE) {
            Drawable d = mDrawable;
            canvas.save();

            Paint.FontMetricsInt fontMetricsInt = paint.getFontMetricsInt();
            int fontTop = y + fontMetricsInt.top;
            int fontMetricsHeight = fontMetricsInt.bottom - fontMetricsInt.top;
            int iconHeight = d.getBounds().bottom - d.getBounds().top;
            int iconTop = fontTop + (fontMetricsHeight - iconHeight) / 2;
            canvas.translate(x, iconTop);
            d.draw(canvas);
            canvas.restore();
        } else {
            super.draw(canvas, text, start, end, x, top, y, bottom, paint);
        }
    }

    /**
     * 是否避免父类修改FontMetrics,如果为 false 则会走父类的逻辑, 会导致FontMetrics被更改
     */
    public void setAvoidSuperChangeFontMetrics(boolean avoidSuperChangeFontMetrics) {
        mAvoidSuperChangeFontMetrics = avoidSuperChangeFontMetrics;
    }

}

到处就完成定义啦,这里我只定义了Drawable的加载 字体的替换,字体大小设置,中划线的实现,还有其他的效果,大家可以自行实现的,注释很详细。

英文的string:

    <string name="hr_view_resume"> <![CDATA[ <font color=\"#000000\">HR from </font>
    <face><font color=\"#0689FB\">%1$s</font></face>
    <font color=\"#000000\"> has viewed your resume.</font>
    <font><icon>1</icon> from Company</font>
    <font color=\"#ff6c00\"><size value=\"25\">1500/day</size></font> <del><font color=\"#808080\"><size value=\"18\">org:20000</size></font></del>

]]> </string>

中文的string:

  <string name="hr_view_resume"> <![CDATA[ <face><font color=\"#0689FB\">%1$s</font></face>
    <font color=\"#000000\">的人事专员</font>
    <font color=\"#000000\">查看了你的简历</font>
    <font>来自公司的<icon>1</icon></font>
    <font color=\"#ff6c00\"><size value=\"25\">1500/天</size></font> <del><font color=\"#808080\"><size value=\"18\">原价:20000元</size></font></del>

     ]]> </string>

Activity中的调用


      val content = String.format(getString(R.string.hr_view_resume), "Custom Company")

        //Html的文本展示
        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) {
            mBinding.tvHtmlText.text =
                Html.fromHtml(content, Html.FROM_HTML_MODE_LEGACY, null,
                    CustomerLableHandler(
                        TypefaceUtil.getSFFlower(mContext),
                        R.mipmap.iv_me_red_packet
                    )
                )
        } else {
            mBinding.tvHtmlText.text = Html.fromHtml(content, null,
                CustomerLableHandler(
                    TypefaceUtil.getSFFlower(mContext),
                    R.mipmap.iv_me_red_packet
                )
            )
        }

中英文实现的效果如下:

单标签的自定义和多标签的自定义讲到这来就完成了,是不是很方便呢?常用的一些Span已经给大家封装好了,有需要的也可以看一下源码,跑一下代码。

感觉大家看到这里,如果觉得不错还请点赞支持!

如有错漏与不同意见也请评论指出!

完结!