Android 自定义Html标签

作者: 蜗牛学开车 | 来源:发表于2019-11-13 14:12 被阅读0次

    前言

    我们在Android的日常开发中经常会用到TextView,而在TextView的使用中有可能需要像下面这样使用。


    上面只用了一个TextView就可以实现,有人可能会想到使用Html.fromHtml("...")实现,但是Android原生的font标签是不支持size属性的。我们来看下源码,看下font标签到底支持哪些属性:
        //摘抄自Android API源码(Html类)
        private void startFont(Editable text, Attributes attributes) {
            String color = attributes.getValue("", "color");
            String face = attributes.getValue("", "face");
    
            if (!TextUtils.isEmpty(color)) {
                int c = getHtmlColor(color);
                if (c != -1) {
                    start(text, new Foreground(c | 0xFF000000));
                }
            }
    
            if (!TextUtils.isEmpty(face)) {
                start(text, new Font(face));
            }
        }
    

    可以看到原生只支持colorface两个属性。如果你需要它支持下划线加粗、以及字体大小等属性是不可能。如果你需要font标签支持这些属性就只能通过自定标签的方式实现了。我们可以看到startFont方法是被private void handleStartTag(String tag, Attributes attributes)方法调用的,如下:

        private void handleStartTag(String tag, Attributes attributes) {
            //省略N行代码
             if (tag.equalsIgnoreCase("font")) {
                startFont(mSpannableStringBuilder, attributes);
            } else if (mTagHandler != null) {
                mTagHandler.handleTag(true, tag, mSpannableStringBuilder, mReader);
            }
        }
    

    可以看到,如果我们的文本中有什么标签没有被Html类处理的话最终会调用mTagHandler.handleTag(true, tag, mSpannableStringBuilder, mReader);这段代码。而mTagHandler是可以在调用fromHtml方法是传入的,源码如下:

    /**
         * Returns displayable styled text from the provided HTML string with the legacy flags
         * {@link #FROM_HTML_MODE_LEGACY}.
         *
         * @deprecated use {@link #fromHtml(String, int, ImageGetter, TagHandler)} instead.
         */
        @Deprecated
        public static Spanned fromHtml(String source, ImageGetter imageGetter, TagHandler tagHandler) {
            return fromHtml(source, FROM_HTML_MODE_LEGACY, imageGetter, tagHandler);
        }
    
        /**
         * Returns displayable styled text from the provided HTML string. Any <img> tags in the
         * HTML will use the specified ImageGetter to request a representation of the image (use null
         * if you don't want this) and the specified TagHandler to handle unknown tags (specify null if
         * you don't want this).
         *
         * <p>This uses TagSoup to handle real HTML, including all of the brokenness found in the wild.
         */
        public static Spanned fromHtml(String source, int flags, ImageGetter imageGetter,
                TagHandler tagHandler) {
            Parser parser = new Parser();
            try {
                parser.setProperty(Parser.schemaProperty, HtmlParser.schema);
            } catch (org.xml.sax.SAXNotRecognizedException e) {
                // Should not happen.
                throw new RuntimeException(e);
            } catch (org.xml.sax.SAXNotSupportedException e) {
                // Should not happen.
                throw new RuntimeException(e);
            }
    
            HtmlToSpannedConverter converter =
                    new HtmlToSpannedConverter(source, imageGetter, tagHandler, parser, flags);
            return converter.convert();
        }
    

    上面的方法在API24之后就淘汰了,所以API24之后要用下面的,API24之前要用上面的。每个参数的意义就不详细介绍了(不是本文重点),主要来看下TagHandler这个接口。他只有一个抽象法方法handleTag,如下:

    public static interface TagHandler {
        public void handleTag(boolean opening, String tag,Editable output, XMLReader xmlReader);
    }
    
    参数说明:

    opening:从Html的源码中可以看出,处理标签开始时该参数为true,处理结束时该参数为false。例如<font>Holle</font>当读取到<font>时为开始,当读取到</font>时为结束。

    tag:标签名字,例如<font>Holle</font>font就是tag参数的值。

    output:已经被处理到的你的文本源。

    xmlReader:封装了所有tag标签的参数,如<font color=#000000>Holle</font>中color的值就从这个参数中读取。但不幸的是我们并不能直接从xmlReader读取我们的自定义参数,这里需要用到反射。核心代码如下:

    Kotlin

    /**
     * 利用反射获取html标签的属性值。使用该方法获取自定义属性等号后面的值。
     * 
     * @param xmlReader XMLReader对象。
     * @param property 你的自定义属性,例如color。
     */
    @Suppress("UNCHECKED_CAST")
    private fun getProperty(xmlReader: XMLReader, property: String): String? {
        try {
            val elementField = xmlReader.javaClass.getDeclaredField("theNewElement")
            elementField.isAccessible = true
            val element: Any = elementField.get(xmlReader)
            val attsField = element.javaClass.getDeclaredField("theAtts")
            attsField.isAccessible = true
            val atts: Any = attsField.get(element)
            val dataField = atts.javaClass.getDeclaredField("data")
            dataField.isAccessible = true
            val data = dataField.get(atts) as Array<String>
            val lengthField = atts.javaClass.getDeclaredField("length")
            lengthField.isAccessible = true
            val len = lengthField.getInt(atts)
            for (i in 0 until len) {
                // 判断属性名
                if (property == data[i * 5 + 1]) {
                    return data[i * 5 + 4]
                }
            }
        } catch (e: Exception) {
            e.printStackTrace()
        }
        return null
    }
    

    好,既然参数都明白了那么就开始来实现吧。首先我们来定义一个CustomTagHandler并重写他的抽象方法handleTag,然后根据opening参数判断当前是处理tag标签的开始还是结束。代码如下:

    override fun handleTag(opening: Boolean, tag: String, output: Editable, xmlReader: XMLReader) {
        if (opening) {
            handlerStartTAG(tag, output, xmlReader)
        } else {
            handlerEndTAG(tag, output)
        }
    }
    

    在上面的代码中我们根据opening参数判断当前是处理tag标签的开始还是结束,如果是开始就调用handlerStartTAG否者则调用handlerEndTAG方法。
    下面在来看下这个两个方法的实现:

    private fun handlerStartTAG(tag: String, output: Editable, xmlReader: XMLReader) {
        if (tag.equals("kFont", ignoreCase = true)) {
            handlerKFontStart(output, xmlReader)
        }
    }
    
    private fun handlerEndTAG(tag: String, output: Editable) {
        if (tag.equals("kFont", ignoreCase = true)) {
            handlerKFontEnd(output)
        }
    }
    

    这两个方法的主要作用是区分我们的自定义标签,只要我们能识别的标签我们才去处理。这里在检测到是我们自定义的标签后分别调用了handlerKFontStarthandlerKFontEnd方法(其实这里你可以将你的自定义标签封装成类,然后所有的处理都在你自己的类中处理,这样的话方便以后的扩展。我们现在是demo,demo还是简单点儿好。),handlerKFontStart主要是记录我们标签开始的位置,以及获取我们所有的参数的值,例如我们的标签只有一个size属性和一个clickable属性。代码如下:

    private fun handlerKFontStart(output: Editable, xmlReader: XMLReader) {
        val index = output.length
        val tagInfo = TagInfo(index)
        
        val clickable = getProperty(xmlReader, "clickable")
        if (!clickable.isNullOrEmpty()) {
            tagInfo.clickable = clickable
        }
        val size = getProperty(xmlReader, "size")
        if (!size.isNullOrEmpty()) {
            tagInfo.size = when {
                size.endsWith("sp", true) -> Integer.parseInt(size.replace("sp", "", true))
                size.endsWith("px", true) -> {
                    tagInfo.hasUnderline = false
                    Integer.parseInt(size.replace("px", "", true))
                }
                else -> try {
                    Integer.parseInt(size)
                } catch (e: Exception) {
                    20
                }
            }
        }
        currentTagInfo = tagInfo
    }
    

    这里我们主要是定义一个实体类TagInfo用来记录我们自定义tag的各个属性以及所在位置。获取参数值的getProperty方法刚刚都已经贴出来了,直接使用即可。接下来就是处理标签结束的时候(handlerKFontEnd方法),具体代码如下:

    private fun handlerKFontEnd(output: Editable) {
        val tagInfo = currentTagInfo
        if (tagInfo != null) {
            val size = tagInfo.size
            val clickable = tagInfo.clickable
            val end = output.length
            if (!clickable.isNullOrEmpty()) {
                output.setSpan(KFontClickableSpan(clickable), tagInfo.startIndex, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
            }
            if (size > 0) {
                output.setSpan(
                    AbsoluteSizeSpan(size, tagInfo.sizeDip),
                    tagInfo.startIndex,
                    end,
                    Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
                )
            }
        }
    }
    

    这个方法中主要就是获取我们记录下来的TagInfo然后根据这个属性设置自己的Span,这里只列举了两个简单的Span,有关更详细的SpannableString的用法请参考这篇博客
    一切完成之后在调用Html.fromHtml()方法的时候传入我们的CustomTagHandler就可以了,代码如下:

    val source = "<kFont color=#F0F000 size=34>我</kFont>是一个<kFont color=#00FF00 size=14>小小小</kFont>鸟"
    tvText.text = Html.fromHtml(source, null, CustomTagHandler())
    

    好了,到这里基本就算结束了,下面是我封装的一个工具类,上面的代码都是摘抄自这个工具类,你可以直接把这个类拿去用(请叫我雷锋,哈哈)。

    class KFontHandler private constructor(private var onTextClickListener: ((flag: String) -> Unit)? = null) :
        Html.TagHandler {
    
        companion object {
            private const val TAG_K_FONT = "kFont"
    
            /**
             * 格式化html文本。
             * @param source 要格式化的html文本,除了支持Google原生支持的标签外,还支持kFont标签。
             * @param textClickListener 如果你给kFont标签添加了clickable属性则可以通过该参数设置点击监听,监听中的flag参数为clickable等号后面的内容,例如
             * 你kFont标签中clickable属性的为`clickable=A`,那么flag的值就为A。如果你没有使用clickable属性则该参数可以不传。
             *
             * @return 返回格式化后的文本(CharSequence类型)。
             */
            fun format(source: String, textClickListener: ((flag: String) -> Unit)? = null): CharSequence {
                return htmlFrom(source.replace("\n", "<br/>", true), textClickListener)
            }
    
            /**
             * 格式化html文本。
             * @param context 上下文参数,用来读取资源文件中的文本。
             * @param resId 要格式化的html文本文件的ID,例如R.raw.html_text。除了支持Google原生支持的标签外,还支持kFont标签。
             * @param textClickListener 如果你给kFont标签添加了clickable属性则可以通过该参数设置点击监听,监听中的flag参数为clickable等号后面的内容,例如
             * 你kFont标签中clickable属性的为`clickable=A`,那么flag的值就为A。如果你没有使用clickable属性则该参数可以不传。
             *
             * @return 返回格式化后的文本(CharSequence类型)。
             */
            fun loadResource(
                context: Context, @RawRes resId: Int,
                textClickListener: ((flag: String) -> Unit)? = null
            ): CharSequence {
                return htmlFrom(getStringFromStream(context.resources.openRawResource(resId)), textClickListener)
            }
    
            private fun htmlFrom(source: String, textClickListener: ((flag: String) -> Unit)? = null): CharSequence {
                return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
                    Html.fromHtml(
                        if (source.startsWith("<html>")) source else "<html>$source</html>",
                        Html.FROM_HTML_MODE_LEGACY,
                        null,
                        KFontHandler(textClickListener)
                    )
                } else {
                    Html.fromHtml(
                        if (source.startsWith("<html>")) source else "<html>$source</html>",
                        null,
                        KFontHandler(textClickListener)
                    )
                }
            }
    
            private fun getStringFromStream(inputStream: InputStream): String {
                val inputStreamReader = InputStreamReader(inputStream, "UTF-8")
                val reader = BufferedReader(inputStreamReader)
                val sb = StringBuffer("")
                var line = reader.readLine()
                while (line != null) {
                    sb.append(line)
                    sb.append("<br/>")
                    line = reader.readLine()
                }
                return sb.toString()
            }
        }
    
        private var currentTagInfo: TagInfo? = null
    
        override fun handleTag(opening: Boolean, tag: String, output: Editable, xmlReader: XMLReader) {
            if (opening) {
                handlerStartTAG(tag, output, xmlReader)
            } else {
                handlerEndTAG(tag, output)
            }
        }
    
        private fun handlerStartTAG(tag: String, output: Editable, xmlReader: XMLReader) {
            if (tag.equals(TAG_K_FONT, ignoreCase = true)) {
                handlerKFontStart(output, xmlReader)
            }
        }
    
        private fun handlerEndTAG(tag: String, output: Editable) {
            if (tag.equals(TAG_K_FONT, ignoreCase = true)) {
                handlerKFontEnd(output)
            }
        }
    
        private fun handlerKFontStart(output: Editable, xmlReader: XMLReader) {
            val index = output.length
            val tagInfo = TagInfo(index)
    
            val style = getProperty(xmlReader, "style")
            if (!style.isNullOrEmpty()) {
                tagInfo.style = when (style) {
                    "b", "bold" -> Typeface.BOLD
                    "i", "italic" -> Typeface.ITALIC
                    "b_i", "i_b", "bold_italic", "italic_bold" -> Typeface.BOLD_ITALIC
                    "u", "underline" -> {
                        tagInfo.hasUnderline = true
                        Typeface.NORMAL
                    }
                    "i_u", "u_i", "italic_underline", "underline_italic" -> {
                        tagInfo.hasUnderline = true
                        Typeface.ITALIC
                    }
                    "b_u", "u_b", "bold_underline", "underline_bold" -> {
                        tagInfo.hasUnderline = true
                        Typeface.BOLD
                    }
                    "b_u_i",
                    "b_i_u",
                    "u_b_i",
                    "u_i_b",
                    "i_u_b",
                    "i_b_u",
                    "italic_bold_underline",
                    "italic_underline_bold",
                    "underline_italic_bold",
                    "underline_bold_italic",
                    "bold_underline_italic",
                    "bold_italic_underline" -> {
                        tagInfo.hasUnderline = true
                        Typeface.BOLD_ITALIC
                    }
                    else -> Typeface.NORMAL
                }
            }
            val clickable = getProperty(xmlReader, "clickable")
            if (!clickable.isNullOrEmpty()) {
                tagInfo.clickable = clickable
            }
            val size = getProperty(xmlReader, "size")
            if (!size.isNullOrEmpty()) {
                tagInfo.size = when {
                    size.endsWith("sp", true) -> Integer.parseInt(size.replace("sp", "", true))
                    size.endsWith("px", true) -> {
                        tagInfo.hasUnderline = false
                        Integer.parseInt(size.replace("px", "", true))
                    }
                    else -> try {
                        Integer.parseInt(size)
                    } catch (e: Exception) {
                        20
                    }
                }
            }
            val color = getProperty(xmlReader, "color")
            if (!color.isNullOrEmpty()) {
                tagInfo.color = color
            }
            currentTagInfo = tagInfo
        }
    
        private fun handlerKFontEnd(output: Editable) {
            val tagInfo = currentTagInfo
            if (tagInfo != null) {
                val color = tagInfo.color
                val size = tagInfo.size
                val style = tagInfo.style
                val clickable = tagInfo.clickable
                val end = output.length
                if (!clickable.isNullOrEmpty()) {
                    output.setSpan(KFontClickableSpan(clickable), tagInfo.startIndex, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
                }
                if (!color.isNullOrEmpty()) {
                    output.setSpan(
                        ForegroundColorSpan(Color.parseColor(color)),
                        tagInfo.startIndex,
                        end,
                        Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
                    )
                }
                if (size > 0) {
                    output.setSpan(
                        AbsoluteSizeSpan(size, tagInfo.sizeDip),
                        tagInfo.startIndex,
                        end,
                        Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
                    )
                }
                if (style != Typeface.NORMAL) {
                    output.setSpan(StyleSpan(style), tagInfo.startIndex, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
                }
                if (tagInfo.hasUnderline) {
                    output.setSpan(UnderlineSpan(), tagInfo.startIndex, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
                }
            }
        }
    
        /**
         * 利用反射获取html标签的属性值
         */
        @Suppress("UNCHECKED_CAST")
        private fun getProperty(xmlReader: XMLReader, property: String): String? {
            try {
                val elementField = xmlReader.javaClass.getDeclaredField("theNewElement")
                elementField.isAccessible = true
                val element: Any = elementField.get(xmlReader)
                val attsField = element.javaClass.getDeclaredField("theAtts")
                attsField.isAccessible = true
                val atts: Any = attsField.get(element)
                val dataField = atts.javaClass.getDeclaredField("data")
                dataField.isAccessible = true
                val data = dataField.get(atts) as Array<String>
                val lengthField = atts.javaClass.getDeclaredField("length")
                lengthField.isAccessible = true
                val len = lengthField.getInt(atts)
                for (i in 0 until len) {
                    // 判断属性名
                    if (property == data[i * 5 + 1]) {
                        return data[i * 5 + 4]
                    }
                }
            } catch (e: Exception) {
                e.printStackTrace()
            }
            return null
        }
    
        private inner class TagInfo internal constructor(val startIndex: Int) {
            internal var style: Int = Typeface.NORMAL
            internal var hasUnderline: Boolean = false
            internal var clickable: String? = null
            internal var color: String? = null
            internal var size: Int = 0
                set(value) {
                    if (value > 0) {
                        field = value
                    }
                }
            internal var sizeDip: Boolean = true
        }
    
        private inner class KFontClickableSpan(private val flag: String) : ClickableSpan() {
            override fun onClick(widget: View) {
                onTextClickListener?.invoke(flag)
            }
    
            override fun updateDrawState(ds: TextPaint) {
            }
        }
    }
    

    如果你觉的本文对你有用还请不要吝啬你的赞哦,你的鼓励将会转换成我继续创作下去的勇气。再次感谢。


    最后还是奉上Demo地址

    相关文章

      网友评论

        本文标题:Android 自定义Html标签

        本文链接:https://www.haomeiwen.com/subject/zvrqictx.html