Compose AnnotatedString实现Html样式解析

前言

在之前的一些文章中,我们通过ComposeUI实现过很多效果,主要内容也涉及到了很多常见的开发方法,无论Android还是跨平台的Compose,本质上都是Canvas绘制,只不过矩阵、方法、状态机制、UI声明方法有所不同。

下面是之前的一些文章

本篇我们来了解一下AnnotatedString实现Html解析,很长时间我们知道,官方对Html的解析支持不完善,在之前的文章中我们也反复提到过。

但现实是,这个并不是伪需求,因此,很多开发者都很期待官方实现。

在大家满怀期待之时,官方确实也实现了,在compose 1.8的源码中,我们能找到相关线索,具体链接《Html.android.kt》,然而,这个源码似乎并不开放给开发者,也就是在开发过程中,目前仅仅用于测试。

原理解析

未公开的原因

既然不能开放给开发者,我们是不是就没有介绍的必要了?

显然,这个想法是错误的。

具体不开放的可能原因是,Compose UI目前在极力追求实现跨平台,而这个实现仅仅兼容了Android平台,其次还需要深度依赖Android的Spannable。当然,后续是否开放还需要进一步跟进。

另外一方面,虽然官方并没有实现,但并不妨碍开发者的实现,后续我们介绍一个比较优秀的github开源项目。

原理

那么,具体是怎么实现的,其实很简单,就是借助了Android自带的Html TagHandler实现html标签解析,TagHandler的具体用法可以参考《Android 实现html css富文本解析引擎》,然后通过Android.Companion扩展一个fromHtml的函数,在内部利用AnnotatedString的Builder将各种Spannable相关标签转换为AnnotatedString相关Style。

为什么可以转换呢,因为我们知道,AnnotatedString和SpannableString用法非常相似。在Android中,无论是TagHandler和SpannableString,其内部都是通过对文本添加标记实现渲染的,他们的最终目标都是一样的,当然,AnnotatedString也完全沿袭了SpannableString的绝大部分优点。对比一下核心方法,我们就能一眼看出来。

下面是AnnotatedString的核心方法

kotlin 复制代码
fun addStyle(style: SpanStyle, start: Int, end: Int) {
    spanStyles.add(MutableRange(item = style, start = start, end = end))
}

下面是SpannableString的核心方法

java 复制代码
public void setSpan(Object what, int start, int end, int flags) {
    super.setSpan(what, start, end, flags);
}

AnnotatedString仅仅少了flags,但在使用中,SpannableString大部份flags都是闭合的,也就是样式在start -> end范围内有效,超出范围无效,显然AnnotatedString也就没太大必要用这个了。

具体代码

下面是官方Html.android.kt的源码,原理很简单,就是使用Android的TagHandler进行解析,然后使用addStyle将Android的各种Spannable标签转换为特定样式。

kotlin 复制代码
fun AnnotatedString.Companion.fromHtml(
    htmlString: String,
    linkStyles: TextLinkStyles? = null,
    linkInteractionListener: LinkInteractionListener? = null,
): AnnotatedString {
    // Check ContentHandlerReplacementTag kdoc for more details
    val stringToParse = "<$ContentHandlerReplacementTag />$htmlString"
    val spanned =
        HtmlCompat.fromHtml(stringToParse, HtmlCompat.FROM_HTML_MODE_COMPACT, null, TagHandler)
    return spanned.toAnnotatedString(linkStyles, linkInteractionListener)
}

@VisibleForTesting
internal fun Spanned.toAnnotatedString(
    linkStyles: TextLinkStyles? = null,
    linkInteractionListener: LinkInteractionListener? = null,
): AnnotatedString {
    return AnnotatedString.Builder(capacity = length)
        .append(this)
        .also { it.addSpans(this, linkStyles, linkInteractionListener) }
        .toAnnotatedString()
}

private fun AnnotatedString.Builder.addSpans(
    spanned: Spanned,
    linkStyles: TextLinkStyles?,
    linkInteractionListener: LinkInteractionListener?,
) {
    spanned.getSpans(0, length, Any::class.java).forEach { span ->
        val range = TextRange(spanned.getSpanStart(span), spanned.getSpanEnd(span))
        addSpan(span, range.start, range.end, linkStyles, linkInteractionListener)
    }
}

private fun AnnotatedString.Builder.addSpan(
    span: Any,
    start: Int,
    end: Int,
    linkStyles: TextLinkStyles?,
    linkInteractionListener: LinkInteractionListener?,
) {
    when (span) {
        is AbsoluteSizeSpan -> {
            // TODO(soboleva) need density object or make dip/px new units in TextUnit
        }
        is AlignmentSpan -> {
            addStyle(span.toParagraphStyle(), start, end)
        }
        is AnnotationSpan -> {
            addStringAnnotation(span.key, span.value, start, end)
        }
        is BackgroundColorSpan -> {
            addStyle(SpanStyle(background = Color(span.backgroundColor)), start, end)
        }
        is BulletSpanWithLevel -> {
            addBullet(
                start = start,
                end = end,
                indentation = Bullet.DefaultIndentation * span.indentationLevel,
                bullet = span.bullet,
            )
        }
        is ForegroundColorSpan -> {
            addStyle(SpanStyle(color = Color(span.foregroundColor)), start, end)
        }
        is RelativeSizeSpan -> {
            addStyle(SpanStyle(fontSize = span.sizeChange.em), start, end)
        }
        is StrikethroughSpan -> {
            addStyle(SpanStyle(textDecoration = TextDecoration.LineThrough), start, end)
        }
        is StyleSpan -> {
            span.toSpanStyle()?.let { addStyle(it, start, end) }
        }
        is SubscriptSpan -> {
            addStyle(SpanStyle(baselineShift = BaselineShift.Subscript), start, end)
        }
        is SuperscriptSpan -> {
            addStyle(SpanStyle(baselineShift = BaselineShift.Superscript), start, end)
        }
        is TypefaceSpan -> {
            addStyle(span.toSpanStyle(), start, end)
        }
        is UnderlineSpan -> {
            addStyle(SpanStyle(textDecoration = TextDecoration.Underline), start, end)
        }
        is URLSpan -> {
            span.url?.let { url ->
                val link = LinkAnnotation.Url(url, linkStyles, linkInteractionListener)
                addLink(link, start, end)
            }
        }
    }
}

private fun AlignmentSpan.toParagraphStyle(): ParagraphStyle {
    val alignment =
        when (this.alignment) {
            Layout.Alignment.ALIGN_NORMAL -> TextAlign.Start
            Layout.Alignment.ALIGN_CENTER -> TextAlign.Center
            Layout.Alignment.ALIGN_OPPOSITE -> TextAlign.End
            else -> TextAlign.Unspecified
        }
    return ParagraphStyle(textAlign = alignment)
}

private fun StyleSpan.toSpanStyle(): SpanStyle? {
    /**
     * StyleSpan doc: styles are cumulative -- if both bold and italic are set in separate spans, or
     * if the base style is bold and a span calls for italic, you get bold italic. You can't turn
     * off a style from the base style.
     */
    return when (style) {
        Typeface.BOLD -> {
            SpanStyle(fontWeight = FontWeight.Bold)
        }
        Typeface.ITALIC -> {
            SpanStyle(fontStyle = FontStyle.Italic)
        }
        Typeface.BOLD_ITALIC -> {
            SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic)
        }
        else -> null
    }
}

private fun TypefaceSpan.toSpanStyle(): SpanStyle {
    val fontFamily =
        when (family) {
            FontFamily.Cursive.name -> FontFamily.Cursive
            FontFamily.Monospace.name -> FontFamily.Monospace
            FontFamily.SansSerif.name -> FontFamily.SansSerif
            FontFamily.Serif.name -> FontFamily.Serif
            else -> {
                optionalFontFamilyFromName(family)
            }
        }
    return SpanStyle(fontFamily = fontFamily)
}

/**
 * Mirrors [androidx.compose.ui.text.font.PlatformTypefaces.optionalOnDeviceFontFamilyByName]
 * behavior with both font weight and font style being Normal in this case
 */
private fun optionalFontFamilyFromName(familyName: String?): FontFamily? {
    if (familyName.isNullOrEmpty()) return null
    val typeface = Typeface.create(familyName, Typeface.NORMAL)
    return typeface
        .takeIf {
            typeface != Typeface.DEFAULT &&
                typeface != Typeface.create(Typeface.DEFAULT, Typeface.NORMAL)
        }
        ?.let { FontFamily(it) }
}

private val TagHandler =
    object : TagHandler {
        override fun handleTag(
            opening: Boolean,
            tag: String?,
            output: Editable?,
            xmlReader: XMLReader?,
        ) {
            if (xmlReader == null || output == null) return

            if (opening && tag == ContentHandlerReplacementTag) {
                val currentContentHandler = xmlReader.contentHandler
                xmlReader.contentHandler = AnnotationContentHandler(currentContentHandler, output)
            }
        }
    }

private class AnnotationContentHandler(
    private val contentHandler: ContentHandler,
    private val output: Editable,
) : ContentHandler by contentHandler {

    // We handle the ul/li tags manually since default implementation will add newlines but we
    // instead want to add the ParagraphStyle
    private var bulletIndentation = 0
    private var currentBulletSpan: BulletSpanWithLevel? = null

    override fun startElement(uri: String?, localName: String?, qName: String?, atts: Attributes?) {
        when (localName) {
            AnnotationTag -> atts?.let { handleAnnotationStart(it) }
            Ul -> handleUlStart()
            Li -> handleLiStart()
            else -> contentHandler.startElement(uri, localName, qName, atts)
        }
    }

    override fun endElement(uri: String?, localName: String?, qName: String?) {
        when (localName) {
            AnnotationTag -> handleAnnotationEnd()
            Ul -> handleUlEnd()
            Li -> handleLiEnd()
            else -> contentHandler.endElement(uri, localName, qName)
        }
    }

    private fun handleAnnotationStart(attributes: Attributes) {
        // Each annotation can have several key/value attributes. So for
        // <annotation key1=value1 key2=value2>...<annotation>
        // example we will add two [AnnotationSpan]s which we'll later read
        for (i in 0 until attributes.length) {
            val key = attributes.getLocalName(i).orEmpty()
            val value = attributes.getValue(i).orEmpty()
            if (key.isNotEmpty() && value.isNotEmpty()) {
                val start = output.length
                // add temporary AnnotationSpan to the output to read it when handling
                // the closing tag
                output.setSpan(AnnotationSpan(key, value), start, start, SPAN_MARK_MARK)
            }
        }
    }

    private fun handleAnnotationEnd() {
        // iterate through all of the spans that we added when handling the opening tag. Calculate
        // the true position of the span and make a replacement
        output
            .getSpans(0, output.length, AnnotationSpan::class.java)
            .filter { output.getSpanFlags(it) == SPAN_MARK_MARK }
            .fastForEach { annotation ->
                val start = output.getSpanStart(annotation)
                val end = output.length

                output.removeSpan(annotation)
                // only add the annotation if there's a text in between the opening and closing tags
                if (start != end) {
                    output.setSpan(annotation, start, end, SPAN_EXCLUSIVE_EXCLUSIVE)
                }
            }
    }

    private fun handleUlStart() {
        commitCurrentBulletSpan()
        bulletIndentation++
    }

    private fun handleUlEnd() {
        commitCurrentBulletSpan()
        bulletIndentation--
    }

    private fun handleLiStart() {
        // unlike default HtmlCompat, this does not handle styling inside the li tag, for example,
        // <li style="color:red"> is a no-op in terms of applying color. This needs to be handled
        // manually since we can't use default implementation which adds unwanted new lines for us.
        commitCurrentBulletSpan()
        currentBulletSpan = BulletSpanWithLevel(Bullet.Default, bulletIndentation, output.length)
    }

    private fun handleLiEnd() {
        commitCurrentBulletSpan()
    }

    private fun commitCurrentBulletSpan() {
        currentBulletSpan?.let {
            val start = it.start
            val end = output.length
            output.setSpan(it, start, end, SPAN_EXCLUSIVE_EXCLUSIVE)
        }
        currentBulletSpan = null
    }
}

private class AnnotationSpan(val key: String, val value: String)

/**
 * A temporary bullet span that holds a [bullet] object, it [start] position and the
 * [indentationLevel] that starts always from 1
 */
internal data class BulletSpanWithLevel(
    val bullet: Bullet,
    val indentationLevel: Int,
    val start: Int,
)

private const val ContentHandlerReplacementTag = "ContentHandlerReplacementTag"
private const val AnnotationTag = "annotation"
private const val Li = "li"
private const val Ul = "ul"

开源项目

有道是山穷水复疑无路,柳暗花明又一村,在官方开发Html.android.kt的同时,开源项目通过不依赖TagHandler和Android相关类,实现了一个跨平台的项目

《[HTML Converter for Compose Multiplatform(github.com/cbeyls/Html...%25E3%2580%258B "https://github.com/cbeyls/HtmlConverterCompose)%E3%80%8B")

在这个项目中,巧妙的利用ktXml和自定义的TagHandler,避免了引用Android原生类,具体用法也相当简单

scss 复制代码
Text(text = remember(html) { htmlToAnnotatedString(html) })

核心代码如下

kotlin 复制代码
internal class StringHtmlHandler(
    builder: StringBuilder,
    private val compactMode: Boolean
) : HtmlHandler {
    private val textWriter = HtmlTextWriter(builder)
    private var listLevel = 0
    // A negative index means the list is unordered
    private var listIndexes: IntArray = EMPTY_LIST_INDEXES
    private var preformattedLevel = 0
    private var skippedTagsLevel = 0

    override fun onOpenTag(name: String, attributes: (String) -> String?) {
        when (name) {
            "br" -> handleLineBreakStart()
            "hr" -> handleHorizontalRuleStart()
            "p", "blockquote" -> handleBlockStart(2, 0)
            "div", "header", "footer", "main", "nav", "aside", "section", "article",
            "address", "figure", "figcaption",
            "video", "audio" -> handleBlockStart(1, 0)
            "ul", "dl" -> handleListStart(-1)
            "ol" -> handleListStart(1)
            "li" -> handleListItemStart()
            "dt" -> handleDefinitionTermStart()
            "dd" -> handleDefinitionDetailStart()
            "pre" -> handlePreStart()
            "h1", "h2", "h3", "h4", "h5", "h6" -> handleHeadingStart()
            "script", "head", "table", "form", "fieldset" -> handleSkippedTagStart()
        }
    }

    private fun handleLineBreakStart() {
        textWriter.writeLineBreak()
    }

    private fun handleHorizontalRuleStart() {
        textWriter.markBlockBoundary(if (compactMode) 1 else 2, 0)
    }

    private fun handleBlockStart(prefixNewLineCount: Int, indentCount: Int) {
        textWriter.markBlockBoundary(if (compactMode) 1 else prefixNewLineCount, indentCount)
    }

    private fun handleListStart(initialIndex: Int) {
        val currentListLevel = listLevel
        handleBlockStart(if (listLevel == 0) 2 else 1, 0)
        val listIndexesSize = listIndexes.size
        // Ensure listIndexes capacity
        if (currentListLevel == listIndexesSize) {
            listIndexes = if (listIndexesSize == 0) {
                IntArray(INITIAL_LIST_INDEXES_SIZE)
            } else {
                listIndexes.copyOf(listIndexesSize * 2)
            }
        }
        listIndexes[currentListLevel] = initialIndex
        listLevel = currentListLevel + 1
    }

    private fun handleListItemStart() {
        val currentListLevel = listLevel
        handleBlockStart(1, currentListLevel - 1)
        val itemIndex = if (currentListLevel == 0) {
            -1
        } else {
            listIndexes[currentListLevel - 1]
        }
        if (itemIndex < 0) {
            textWriter.write("• ")
        } else {
            textWriter.write(itemIndex.toString())
            textWriter.write(". ")
            listIndexes[currentListLevel - 1] = itemIndex + 1
        }
    }

    private fun handleDefinitionTermStart() {
        handleBlockStart(1, listLevel - 1)
    }

    private fun handleDefinitionDetailStart() {
        handleBlockStart(1, listLevel)
    }

    private fun handlePreStart() {
        handleBlockStart(2, 0)
        preformattedLevel++
    }

    private fun handleHeadingStart() {
        handleBlockStart(2, 0)
    }

    private fun handleSkippedTagStart() {
        skippedTagsLevel++
    }

    override fun onCloseTag(name: String) {
        when (name) {
            "br",
            "hr" -> {}
            "p", "blockquote" -> handleBlockEnd(2)
            "div", "header", "footer", "main", "nav", "aside", "section", "article",
            "address", "figure", "figcaption",
            "video", "audio" -> handleBlockEnd(1)
            "ul", "dl",
            "ol" -> handleListEnd()
            "li" -> handleListItemEnd()
            "dt" -> handleDefinitionTermEnd()
            "dd" -> handleDefinitionDetailEnd()
            "pre" -> handlePreEnd()
            "h1", "h2", "h3", "h4", "h5", "h6" -> handleHeadingEnd()
            "script", "head", "table", "form", "fieldset" -> handleSkippedTagEnd()
        }
    }

    private fun handleBlockEnd(suffixNewLineCount: Int) {
        textWriter.markBlockBoundary(if (compactMode) 1 else suffixNewLineCount, 0)
    }

    private fun handleListEnd() {
        listLevel--
        handleBlockEnd(if (listLevel == 0) 2 else 1)
    }

    private fun handleListItemEnd() {
        handleBlockEnd(1)
    }

    private fun handleDefinitionTermEnd() {
        handleBlockEnd(1)
    }

    private fun handleDefinitionDetailEnd() {
        handleBlockEnd(1)
    }

    private fun handlePreEnd() {
        preformattedLevel--
        handleBlockEnd(2)
    }

    private fun handleHeadingEnd() {
        handleBlockEnd(1)
    }

    private fun handleSkippedTagEnd() {
        skippedTagsLevel--
    }

    override fun onText(text: String) {
        // Skip text inside skipped tags
        if (skippedTagsLevel > 0) {
            return
        }

        if (preformattedLevel == 0) {
            textWriter.write(text)
        } else {
            textWriter.writePreformatted(text)
        }
    }

    companion object {
        private val EMPTY_LIST_INDEXES = intArrayOf()
        private const val INITIAL_LIST_INDEXES_SIZE = 8
    }
}

当然,目前仅仅支持少数标签,img标签依然没有支持。

但我们如果非要实现,你可以clone源码,可以参考Cpmose Text的用法,将img转为相应的InlineContent去兼容。

具体怎么改,可以参考《Compose Text 文本和 AnnotatedString 多种样式的文本详解》这篇文章。

java 复制代码
val annotatedString4 = buildAnnotatedString {
    appendInlineContent(id = "inline1")
    withStyle(style = SpanStyle(fontWeight = FontWeight.Bold, color = Color.Blue)) {
        append("Compose Text ")
    }
    withStyle(style = SpanStyle()) {
        append("通过")
    }
    withStyle(style = SpanStyle(fontStyle = FontStyle.Italic, color = Color.Red)) {
        append(" AnnotatedString ")
    }
    withStyle(style = SpanStyle()) {
        append("设置富文本效果!")
    }
}
Text(
text = annotatedString4, inlineContent = mapOf("inline1" to InlineTextContent(
Placeholder(20.sp, 10.sp, PlaceholderVerticalAlign.TextCenter)
) {
    Box(
        modifier = Modifier
            .width(10.dp)
            .height(10.dp)
            .background(
                color = colorResource(id = R.color.teal_700),
                shape = RoundedCornerShape(5.dp)
            )
    )
})
)
Text(
text = annotatedString4, inlineContent = mapOf("inline1" to InlineTextContent(
Placeholder(20.sp, 20.sp, PlaceholderVerticalAlign.Bottom)
) {
    Image(
        painter = painterResource(id = R.mipmap.ic_icon),
        modifier = Modifier.width(20.dp).height(20.dp),
        contentDescription = ""
    )
})
)

不过,为了通用性,你可能要复写ComposeUI的Text 实现,这里就不再赘述。

总结

本篇,我们主要介绍了官方Compose UI的Html支持能力,以及三方的跨平台支持实现,同时,对于不支持Html img标签的情况,我们也找到了相应的解决方法。

本篇就到这里,希望对你有所帮助。

相关推荐
用户904706683572 小时前
假数据生成器——JSONPlaceholder
前端
光影少年2 小时前
react16中的hooks的底层实现原理
前端·react.js·掘金·金石计划
sorryhc2 小时前
手写一个Webpack HMR插件——彻底搞懂热更新原理
前端·javascript·前端工程化
无双_Joney2 小时前
[更新迭代 - 1] Nestjs 在24年底更新了啥?(bug修复篇)
前端·后端·node.js
xiaoxiao无脸男3 小时前
three.js
开发语言·前端·javascript
90后的晨仔3 小时前
Vue 组件注册详解:全局注册 vs 局部注册
前端·vue.js
前端Hardy3 小时前
HTML&CSS:高颜值交互式日历,贴心记录每一天
前端·javascript·css
hnlgzb3 小时前
安卓中,kotlin如何写app界面?
android·开发语言·kotlin
一只专注做软件的湖南人3 小时前
京东商品评论接口(jingdong.ware.comment.get)技术解析:数据拉取与情感分析优化
前端·后端·api