学会用最优雅的姿式在Compose中显示富文本

本文译自「StyledString: A Better Pattern for Rich Text in Jetpack Compose」,原文链接proandroiddev.com/styledstrin...,由Eury Pérez Beltré发布于2025年7月14日。

在 Jetpack Compose 中设置文本样式看似简单......但其实不然。在本文中,我们将探讨 AnnotatedString 的局限性,以及 StyledString 如何让富文本更易于管理。让我们来详细分析一下。👇

📚 目录

  1. 引言:一个粗体字、一个链接,以及一大堆麻烦
  2. AnnotatedString:样式过多,简洁性不足
  3. StyledString 简介:一个 API 即可设置所有样式
  4. StyledString 底层原理:API 背后的引擎
  5. 结语

引言:一个加粗的单词、一个链接,以及一大堆麻烦

一开始,你拥有了 AnnotatedString 和一个 SpanStyle ,一切看起来都很顺畅。你想加粗一个单词?很简单✅。给某个东西加下划线?没问题。这甚至感觉有点有趣,尤其是在你像官方文档中那样手动构建整个字符串的时候。

但问题是:🧠

当你完全控制字符串时,这种方法非常有效。但当你处理实际内容:动态副本、本地化文本、从其他地方传入的段落,而你只需要设置其中一部分的样式时?

事情很快就变得很糟糕。

突然间,你需要跟踪子字符串、计算索引、应用样式,并连接点击监听器。只需对文本进行一次更改,你的逻辑就会像纸牌屋一样崩溃。🃏

你原本想要的只是加粗一个单词并让链接可点击。现在你深陷于样板代码中,祈祷一切都不会改变。

在这篇文章中,我将解释为什么 AnnotatedString 在实际 UI 中无法很好地扩展,并介绍一个我为了解决这个问题而构建的微型抽象。它叫做 StyledString,它的功能非常强大:💡 它确实做到了:

让Compose 中的文本样式再次变得简单。

AnnotatedString:样式太多,简洁性不足

首先,让我们称赞一下 AnnotatedString。它是一款强大的工具💪.

你可以使用一个 Text 可组合项来创建带样式、可点击、可交互的文本。想要让一个单词加粗,另一个单词像链接一样显示?完全可以。该 API 灵活、底层,并且由 Compose 本身的富文本引擎支持。

问题是,它只有在手动构建整个字符串时才能发挥最佳效果。

文档中的大多数示例如下所示:

Kotlin 复制代码
buildAnnotatedString {
    append("Hello ")
    withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
        append("world")
    }
}

看起来不错,对吧?但棘手的地方就在这里。👀

如果你有一整段动态文本,比如一段本地化的字符串或一个从其他地方拉取的句子,而你只想为其中的部分内容添加样式,该怎么办?

现在你需要处理:

  • 查找要添加样式的子字符串
  • 计算起始和结束索引
  • 手动添加样式或注释
  • 希望文本永远不变,否则一切都会崩溃。

如果你需要多种样式,比如粗体单词、可点击的电子邮件和带下划线的 URL,动态地,事情很快就会变得混乱。🔥 这时,buildAnnotatedString 就会变成一堆脆弱的索引数学运算、重复的逻辑和样板代码,难以阅读,更难以维护。

当然,AnnotatedString 功能强大。但当你的文本是动态的,而你只想为其中的部分内容添加样式时?它很快就会变得索然无味。

StyledString 隆重出场:一个 API 即可实现所有样式

在与 AnnotatedString 纠结了无数次之后,我决定构建一个更好的东西。它并非一个庞大的库,也不是一个完整的样式框架。而是一个简单、兼容 Compose 的抽象,用于解决一个非常具体的问题。

StyledString 来啦!👋

它的目标很简单:让你定义字符串的哪些部分应该被设置样式或可点击,而无需担心 indexOf 、 addStyle 或 AnnotatedString.Builder 。你只需编写文本,告诉它需要设置哪些单词的样式,以及点击后该执行的操作。

它的实际效果如下:

Kotlin 复制代码
// This list can be built in the ViewModel
val styledStrings = persistentListOf(
    StyledString.ClickableEmail(
        highlightedText = "support@example.com",
        email = "support@example.com",
        style = SpanStyle(
            color = Color.Blue,
            textDecoration = TextDecoration.Underline
        )
    ),
    StyledString.ClickableUrl(
        highlightedText = "website",
        url = "https://euryperez.dev",
        style = SpanStyle(
            color = Color.Blue,
            textDecoration = TextDecoration.Underline
        )
    )
)

// In your Compose Screen
StyledText(
    fullText = "Contact us at support@example.com or visit our website",
    styledStrings = styledStrings,
    style = MaterialTheme.typography.label,
    onClick = { styled ->
        when (styled) {
            is ClickableEmail -> openEmailClient(styled.email)
            is ClickableUrl -> openUrl(styled.url)
        }
    }
)

就是这样。无需手动构建文本,无需索引计算,无需样板代码。只需简洁、易读、声明式的样式,即可与真实文本兼容。

由于 StyledString 支持 Simple 、 ClickableEmail 和 ClickableUrl 等类型,因此它易于在你的应用中扩展和复用。你可以获得可点击的、带样式的文本,而无需牺牲其合理性和可维护性。🙏

StyledString 的底层:API 背后的引擎

让我们揭开它的面纱,逐步了解 StyledString 的工作原理。🪄

当你在 UI 中使用 StyledText 时,🧠它可能感觉像魔法一样神奇,但在幕后,它只是一个简洁、易于组合的架构,旨在减少样式设计的痛苦,而不会增加不必要的复杂性。

本节涵盖了 StyledString 系统的每个部分,从样式的描述方式,到样式的查找、应用和在屏幕上渲染。

🧱 1. 数据模型:StyledString 和 ClickableStyleString

整个实用程序的核心是一个名为 StyledString 的密封接口。我们通过它来对需要以某种方式设置样式的文本片段进行建模。

Kotlin 复制代码
@Immutable
sealed interface StyledString {
    val highlightedText: String
    val style: SpanStyle
 
    // Add `Simple` type
  
    // Add `ClickableEmail` type
  
    // Add `ClickableUrl` type
}

每个 StyledString 都需要两条信息:

  • highlightedText:需要设置样式的文本的确切部分
  • style:定义其外观的 SpanStyle(颜色、下划线、字体粗细等)。

然后,我们定义一些实现此接口的特定类型:

Kotlin 复制代码
@Immutable
data class Simple(
    override val highlightedText: String,
    override val style: SpanStyle,
) : StyledString

这个是纯视觉效果的,它会改变文本的外观,但不响应点击。

然后我们介绍交互类型:

Kotlin 复制代码
@Immutable
data class ClickableEmail(
    override val highlightedText: String,
    val email: String,
    override val style: SpanStyle,
) : StyledString, ClickableStyleString

@Immutable
data class ClickableUrl(
    override val highlightedText: String,
    val url: String,
    override val style: SpanStyle
) : StyledString, ClickableStyleString

它们执行相同的样式设置工作,但还携带额外的数据(例如点击时应打开的 URL 或电子邮件)。更重要的是,它们实现了第二个接口:ClickableStyleString。

Kotlin 复制代码
sealed interface ClickableStyleString

这个小接口意义重大,它让我们能够区分纯视觉样式和应该响应点击的样式。这使得我们的点击处理逻辑简洁且类型安全。💡

你可以轻松添加更多变体,例如 @mentions、#hashtags 或电话号码,只需创建另一个数据类并选择性地实现ClickableStyleString 即可。

🎯 2. 样式和链接:applyStyle

一旦我们知道了哪些文本需要样式,我们就需要一种将这些样式应用于实际的 AnnotatedString 的方法。这就是 applyStyle() 的作用,它是一个简单的扩展函数,它根据 StyledString 的类型应用样式(和点击监听器)。

Kotlin 复制代码
private fun AnnotatedString.Builder.applyStyle(
    styledString: StyledString,
    startIndex: Int,
    endIndex: Int,
    onClick: (ClickableStyleString) -> Unit
) {
    when (styledString) {
        is StyledString.ClickableUrl -> TODO()

        is StyledString.ClickableEmail -> TODO()

        is StyledString.Simple -> TODO()
    }
}

每次匹配每个 StyledString 时,都会调用一次此函数。现在让我们看看它做了什么:

Kotlin 复制代码
is StyledString.ClickableUrl -> {
    val linkAnnotation = LinkAnnotation.Url(
        url = styledString.url,
        styles = TextLinkStyles(style = styledString.style),
        linkInteractionListener = { onClick(styledString) }
    )
    addLink(linkAnnotation, startIndex, endIndex)
}

如果是 URL,我们会创建一个 LinkAnnotation.Url 对象,附加样式,并为其添加一个点击监听器。addLink 负责将其附加到正确的文本范围。

我们执行的操作类似,但针对电子邮件使用的是 LinkAnnotation.Clickable :

Kotlin 复制代码
is StyledString.ClickableEmail -> {
    val linkAnnotation = LinkAnnotation.Clickable(
        tag = styledString.highlightedText,
        styles = TextLinkStyles(style = styledString.style),
        linkInteractionListener = { onClick(styledString) }
    )
    addLink(linkAnnotation, startIndex, endIndex)
}

如果样式只是视觉上的(不可点击),我们会应用常规跨度:

Kotlin 复制代码
is StyledString.Simple -> {
    addStyle(
        style = styledString.style, 
        start = startIndex, 
        end = endIndex
    )
}

这种分离将所有样式应用逻辑集中在一处。如果你想要支持新的链接类型或行为,只需更新此函数即可。

🔍 3. 匹配文本:findAllOccurrences

在应用样式之前,我们需要找到文本中所有出现指定highlightedText 的位置。这就是此函数的用途。

Kotlin 复制代码
/**
 * Find all occurrences of a substring in a string, optionally ignoring case.
 *
 * @param substring The substring to search for.
 * @param ignoreCase Whether to perform a case-insensitive search.
 * @return A list of indices where the substring was found.
 */
private fun String.findAllOccurrences(
    substring: String,
    ignoreCase: Boolean = false
): List<Int>

这将获取全文,并返回给定子字符串的每个匹配项的起始索引列表。

工作原理如下:

Kotlin 复制代码
if (substring.isEmpty()) return emptyList()

对于空子字符串,快速提前退出。避免奇怪的边缘情况。然后,我们准备进行不区分大小写的搜索(如果需要):

Kotlin 复制代码
val indices = mutableListOf<Int>()
val searchString = if (ignoreCase) this.lowercase() else this
val searchSubstring = if (ignoreCase) substring.lowercase() else substring

现在我们遍历字符串,找到所有匹配项:

Kotlin 复制代码
var startIndex = 0
val maxStartIndex = length - substring.length

while (startIndex <= maxStartIndex) {
    val index = searchString.indexOf(searchSubstring, startIndex)
    if (index == -1) break
    indices.add(index)
    startIndex = index + 1
}

我们最终返回结果:

Kotlin 复制代码
return indices.toList()

这使得我们的样式逻辑保持灵活性和弹性,无论我们设计的单词出现一次还是十几次。

🧠 4. 构建 AnnotatedString:rememberStyledAnnotationString

以下函数将所有内容整合在一起。它接收完整文本和你的StyledString 列表,并返回一个应用了所有样式的 AnnotatedString。

Kotlin 复制代码
@Composable
fun rememberStyledAnnotationString(
    fullText: String,
    styledStrings: ImmutableList<StyledString>,
    ignoreCase: Boolean = false,
    onClick: (ClickableStyleString) -> Unit
): AnnotatedString

我们确保使用 rememberUpdatedState() 来保持点击监听器的最新状态:

Kotlin 复制代码
val currentOnClick by rememberUpdatedState(onClick)

然后我们使用记住来缓存工作,除非输入发生变化:

Kotlin 复制代码
return remember(fullText, styledStrings, ignoreCase) {
  // TODO: build annotated string
}

我们首先附加完整的未样式化的文本。然后,对于每个 StyledString ,我们找到所有匹配的位置并应用样式:

Kotlin 复制代码
buildAnnotatedString {
    append(fullText)

    styledStrings.fastForEach { styledStringInfo ->
        val indices =
            fullText.findAllOccurrences(styledStringInfo.highlightedText, ignoreCase)

        indices.fastForEach { startIndex ->
            val endIndex = startIndex + styledStringInfo.highlightedText.length
            applyStyle(styledStringInfo, startIndex, endIndex, currentOnClick)
        }
    }
}

这个循环使得样式设置能够动态且多目标化。你可以将任何本地化或运行时生成的字符串作为 fullText 传递,它仍然能够正确应用样式。

🧩 5. 可组合项:StyledText

最后,StyledText 可组合项将所有内容连接在一起。

Kotlin 复制代码
@Composable
fun StyledText(
    fullText: String,
    styledStrings: ImmutableList<StyledString>,
    style: TextStyle,
    modifier: Modifier = Modifier,
    onClick: (ClickableStyleString) -> Unit = {},
    ignoreCase: Boolean = false,
) {
    // TODO: Implementation
}

你传入全文、样式以及可选的点击处理程序。它的内部功能如下:

Kotlin 复制代码
val annotatedString = rememberStyledAnnotationString(
    fullText = fullText,
    styledStrings = styledStrings,
    ignoreCase = ignoreCase,
    onClick = onClick
)

这调用了我们刚刚讲过的逻辑。它返回一个带样式的 AnnotatedString 。然后我们渲染它:

Kotlin 复制代码
Text(
    modifier = modifier,
    text = annotatedString,
    style = style
)

它只是一个普通的 Compose Text 。但所有样式逻辑都已预先烘焙。现在,你的 UI 代码保持简洁且声明式。🌚

⚡️ 6. StyledText 实践

现在,让我们来看看 StyledText 的实践,为此,我整理了一个预览,你可以自己测试一下:

Kotlin 复制代码
@PreviewLightDark
@Composable
private fun StyledTextPreview() {
    MyTheme {
        Box(
            modifier = Modifier
                .background(color = MaterialTheme.colors.background)
                .padding(16.dp)
        ) {
            // This list can be built in the ViewModel
            val styledStrings = persistentListOf(
                StyledString.ClickableEmail(
                    highlightedText = "support@example.com",
                    email = "support@example.com",
                    style = SpanStyle(
                        color = Color.Gray,
                        textDecoration = TextDecoration.Underline
                    )
                ),
                StyledString.ClickableUrl(
                    highlightedText = "website",
                    url = "https://euryperez.dev",
                    style = SpanStyle(
                        color = Color.Gray,
                        textDecoration = TextDecoration.Underline
                    )
                )
            )

            // In your Compose Screen
            StyledText(
                fullText = "Contact us at support@example.com or visit our website",
                styledStrings = styledStrings,
                style = MaterialTheme.typography.body2,
                color = MaterialTheme.colors.onBackground,
                onClick = { styled ->
                    when (styled) {
                        is StyledString.ClickableEmail -> TODO()
                        is StyledString.ClickableUrl -> TODO()
                    }
                }
            )
        }
    }
}

你将在预览中看到以下内容:

✅ 总结:一个输出简洁的简单引擎

总而言之,我们构建了一个完全可复用的 Compose 实用程序,它:

  • 使用 StyledString 以声明式方式描述样式
  • 安全地区分可视样式和可点击样式
  • 使用 applyStyle 应用 span 和 link
  • 使用 findAllOccurrences 查找多个匹配项
  • 以 Compose 稳定的方式组装所有内容
  • 封装在一个简洁的 API 中:StyledText

无需 indexOf ,无需复杂的范围逻辑,也无需复制粘贴 buildAnnotatedString样板代码。

点击此处 获取完整解决方案。

结语🎯

Jetpack Compose 赋予我们强大的功能,但并非总是最符合人体工程学的开箱即用工具。AnnotatedString 对于一次性需求来说非常棒,但一旦你的 UI 需要多种样式、复用模式或动态点击处理,它就会很快变得冗长。

这就是 StyledString 的用武之地。

它并非取代 AnnotatedString,而是对其进行包装,为你提供一种更安全、更清晰的方式来描述意图:

  • → "将此单词加粗"
  • → "将此短语设为链接"
  • → "为该字符串的每个实例设置样式"

你无需再考虑文本偏移量和跨度范围,而是开始思考含义。结果:代码更简洁、样板更少,开发者体验更佳💆

🧩 易于采用

你无需重构整个应用即可使用 StyledString 。

只需将一两个 Text() 元素替换为 StyledText() 即可。将内联的 buildAnnotatedString { ... } 块替换为 StyledString.Simple 或 ClickableUrl 的简单列表即可。

就这样,你就成功了。✨

🛠️ 易于扩展

还有其他用例吗?

  • 为 #hashtags 设置样式?
  • 处理 @mentions?
  • 自动检测电话号码?
  • 添加图标或背景高亮?

只需创建一个实现 StyledString 的新数据类,并在 applyStyle() 中处理它即可。系统的其余部分保持不变。

这种分离使你的文本逻辑模块化、可测试,并能够适应未来的设计或业务需求。

如果你有什么有趣的想法,别忘了在评论区分享。😉

🫱 轮到你了

现在你已经了解了它的工作原理(以及它实际需要的代码量有多小),那就在下一个 Compose 屏幕中尝试一下吧。不再需要繁琐的 AnnotatedString.Builder 代码。不再需要重复的 span 逻辑。只需描述你想要的内容,剩下的交给 StyledText 处理。

让 Compose 中的文本样式再次变得简单。😎

🤝 感谢阅读

如果你最终在项目中使用了 StyledString,请告诉我!看到这些微型模式在现实世界中落地总是很酷。👀

感谢阅读!如果你觉得这篇文章有用,请考虑分享给其他开发者,点赞或留言。这很有帮助。✌️

欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!

保护原创,请勿转载!

相关推荐
xiangxiongfly9151 小时前
Android 圆形和圆角矩形总结
android·圆形·圆角·imageview
幻雨様7 小时前
UE5多人MOBA+GAS 45、制作冲刺技能
android·ue5
Jerry说前后端8 小时前
Android 数据可视化开发:从技术选型到性能优化
android·信息可视化·性能优化
Meteors.9 小时前
Android约束布局(ConstraintLayout)常用属性
android
alexhilton10 小时前
玩转Shader之学会如何变形画布
android·kotlin·android jetpack
whysqwhw14 小时前
安卓图片性能优化技巧
android
风往哪边走14 小时前
自定义底部筛选弹框
android
Yyyy48215 小时前
MyCAT基础概念
android
Android轮子哥15 小时前
尝试解决 Android 适配最后一公里
android
雨白16 小时前
OkHttp 源码解析:enqueue 非同步流程与 Dispatcher 调度
android