前言
在之前的一些文章中,我们通过ComposeUI实现过很多效果,主要内容也涉及到了很多常见的开发方法,无论Android还是跨平台的Compose,本质上都是Canvas绘制,只不过矩阵、方法、状态机制、UI声明方法有所不同。
下面是之前的一些文章
- 《Android Compose为列表添加Header和Footer》
- 《Android Compose宫格拖拽效果实现》
- 《Android Compose UI绘制翻页效果实践》
- 《Android Jetpack Compose开发体验》
- 《Android 模拟Compose软键盘交互》
- 《Android Compose 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标签的情况,我们也找到了相应的解决方法。
本篇就到这里,希望对你有所帮助。