Android 自定義Html標籤

#前言
我們在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地址

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章