You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何使用正则解析Jetpack Compose的AnnotatedString并保留样式?

处理Jetpack Compose中AnnotatedString正则替换并保留样式的问题

问题说明

在Jetpack Compose中,直接调用AnnotatedString的trim()或replace()方法会返回普通CharSequence,丢失样式信息。如果手动复用原spanStyles/paragraphStyles并结合修改后的文本,会因为文本长度变化导致样式索引与新文本不匹配,抛出IndexOutOfBoundsException。

示例代码

val text = buildAnnotatedString {
    withStyle(style = SpanStyle(fontSize = 30.sp)) {
        append("   How")
    }
    withStyle(style = SpanStyle(color = Color.Red)) {
        append("\n\n")
        append("are you?   ")
    }
}

@Composable
fun Test() = Column {
    Text(text)
    // 此写法会导致样式索引越界
    Text(
        AnnotatedString(
            text = text.text.replace(Regex("(\n)+"), " "),
            spanStyles = text.spanStyles,
            paragraphStyles = text.paragraphStyles
        )
    )
    // Text(text.trim().toString()) // 同样丢失样式或导致异常
}

异常栈信息

Process: com.uzential.speedreadingendless, PID: 12758
java.lang.IndexOutOfBoundsException: setSpan (6 ... 19) ends beyond length 18
    at android.text.SpannableStringInternal.checkRange(SpannableStringInternal.java:493)
    at android.text.SpannableStringInternal.setSpan(SpannableStringInternal.java:199)
    at android.text.SpannableStringInternal.setSpan(SpannableStringInternal.java:186)
    at android.text.SpannableString.setSpan(SpannableString.java:60)
    at androidx.compose.ui.text.platform.extensions.SpannableExtensions_androidKt.setSpan(SpannableExtensions.android.kt:81)
    at androidx.compose.ui.text.platform.extensions.SpannableExtensions_androidKt.setColor-RPmYEkk(SpannableExtensions.android.kt:522)
    at androidx.compose.ui.text.platform.AndroidAccessibilitySpannableString_androidKt.setSpanStyle(AndroidAccessibilitySpannableString.android.kt:95)
    at androidx.compose.ui.text.platform.AndroidAccessibilitySpannableString_androidKt.toAccessibilitySpannableString(AndroidAccessibilitySpannableString.android.kt:63)
    at androidx.compose.ui.platform.AndroidComposeViewAccessibilityDelegateCompat.getInfoText(AndroidComposeViewAccessibilityDelegateCompat.android.kt:1433)
    at androidx.compose.ui.platform.AndroidComposeViewAccessibilityDelegateCompat.isScreenReaderFocusable(AndroidComposeViewAccessibilityDelegateCompat.android.kt:768)
    at androidx.compose.ui.platform.AndroidComposeViewAccessibilityDelegateCompat.geometryDepthFirstSearch(AndroidComposeViewAccessibilityDelegateCompat.android.kt:700)
    at androidx.compose.ui.platform.AndroidComposeViewAccessibilityDelegateCompat.geometryDepthFirstSearch(AndroidComposeViewAccessibilityDelegateCompat.android.kt:713)
    at androidx.compose.ui.platform.AndroidComposeViewAccessibilityDelegateCompat.subtreeSortedByGeometryGrouping(AndroidComposeViewAccessibilityDelegateCompat.android.kt:735)
    at androidx.compose.ui.platform.AndroidComposeViewAccessibilityDelegateCompat.setTraversalValues(AndroidComposeViewAccessibilityDelegateCompat.android.kt:750)
    at androidx.compose.ui.platform.AndroidComposeViewAccessibilityDelegateCompat.getCurrentSemanticsNodes$ui_release(AndroidComposeViewAccessibilityDelegateCompat.android.kt:346)
    at androidx.compose.ui.platform.AndroidComposeViewAccessibilityDelegateCompat.createEvent$ui_release(AndroidComposeViewAccessibilityDelegateCompat.android.kt:1585)
    at androidx.compose.ui.platform.AndroidComposeViewAccessibilityDelegateCompat.sendEventForVirtualView(AndroidComposeViewAccessibilityDelegateCompat.android.kt:1527)
    at androidx.compose.ui.platform.AndroidComposeViewAccessibilityDelegateCompat.sendEventForVirtualView$default(AndroidComposeViewAccessibilityDelegateCompat.android.kt:1517)
    at androidx.compose.ui.platform.AndroidComposeViewAccessibilityDelegateCompat.sendSubtreeChangeAccessibilityEvents(AndroidComposeViewAccessibilityDelegateCompat.android.kt:2344)
    at androidx.compose.ui.platform.AndroidComposeViewAccessibilityDelegateCompat.boundsUpdatesEventLoop(AndroidComposeViewAccessibilityDelegateCompat.android.kt:2215)
    at androidx.compose.ui.platform.AndroidComposeView.boundsUpdatesEventLoop(AndroidComposeView.android.kt:1270)
    at androidx.compose.ui.platform.WrappedComposition$setContent$1$1$1.invokeSuspend(Wrapper.android.kt:128)
    at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
    at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:106)
    at androidx.compose.ui.platform.AndroidUiDispatcher.performTrampolineDispatch(AndroidUiDispatcher.android.kt:81)
    at androidx.compose.ui.platform.AndroidUiDispatcher.access$performTrampolineDispatch(AndroidUiDispatcher.android.kt:41)
    at androidx.compose.ui.platform.AndroidUiDispatcher$dispatchCallback$1.run(AndroidUiDispatcher.android.kt:57)
    at android.os.Handler.handleCallback(Handler.java:938)
    at android.os.Handler.dispatchMessage(Handler.java:99)
    at android.os.Looper.loopOnce(Looper.java:233)
    at android.os.Looper.loop(Looper.java:344)
    at android.app.ActivityThread.main(ActivityThread.java:8212)
    at java.lang.reflect.Method.invoke(Native Method)
    at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:584)
    at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1034)
Suppressed: kotlinx.coroutines.internal.DiagnosticCoroutineContextException: [androidx.compose.ui.platform.MotionDurationScaleImpl@4674928, androidx.compose.runtime.BroadcastFrameClock@5348641, StandaloneCoroutine{Cancelling}@f8b27e6, AndroidUiDispatcher@64c4127]

当前临时方案

通过subSequence手动修剪空白字符,仅适用于简单的首尾修剪场景:

var start = 0
while (start < text.length && (text[start] == ' ' || text[start] == '\n')) {
    start++
}
var end = text.length - 1
while (end >= 0 && (text[end] == ' ' || text[end] == '\n')) {
    end--
}
val trimmedText = if (start > end) AnnotatedString("") else text.subSequence(start, end + 1) as AnnotatedString

通用解决方案:正则替换并保留样式

核心思路是:文本修改后,同步调整所有样式的索引范围,确保样式与新文本的字符位置匹配。以下是实现正则替换的扩展函数:

fun AnnotatedString.replace(regex: Regex, replacement: String): AnnotatedString {
    val originalText = this.text
    val matches = regex.findAll(originalText).toList()
    if (matches.isEmpty()) return this

    val newText = originalText.replace(regex, replacement)
    // 记录每个匹配位置的长度偏移(替换后长度 - 原匹配长度)
    val offsetRecords = mutableListOf<Pair<Int, Int>>()
    var totalOffset = 0

    matches.forEach { match ->
        val matchLength = match.range.last - match.range.first + 1
        val offset = replacement.length - matchLength
        offsetRecords.add(Pair(match.range.first + totalOffset, offset))
        totalOffset += offset
    }

    // 调整SpanStyle的索引范围
    val adjustedSpanStyles = spanStyles.map { style ->
        var adjustedStart = style.start
        var adjustedEnd = style.end

        offsetRecords.forEach { (matchStart, offset) ->
            if (adjustedStart > matchStart) adjustedStart += offset
            if (adjustedEnd > matchStart) adjustedEnd += offset
        }

        style.copy(
            start = adjustedStart.coerceAtMost(newText.length),
            end = adjustedEnd.coerceAtMost(newText.length)
        )
    }.filter { it.start < it.end } // 过滤无效的样式范围

    // 调整ParagraphStyle的索引范围
    val adjustedParagraphStyles = paragraphStyles.map { style ->
        var adjustedStart = style.start
        var adjustedEnd = style.end

        offsetRecords.forEach { (matchStart, offset) ->
            if (adjustedStart > matchStart) adjustedStart += offset
            if (adjustedEnd > matchStart) adjustedEnd += offset
        }

        style.copy(
            start = adjustedStart.coerceAtMost(newText.length),
            end = adjustedEnd.coerceAtMost(newText.length)
        )
    }.filter { it.start < it.end }

    return AnnotatedString(
        text = newText,
        spanStyles = adjustedSpanStyles,
        paragraphStyles = adjustedParagraphStyles
    )
}

使用方式

直接调用扩展函数即可,无需手动处理样式:

@Composable
fun Test() = Column {
    Text(text)
    // 正则替换换行符为空格,同时保留样式
    Text(text.replace(Regex("(\n)+"), " "))
}

补充:通用trim扩展

针对修剪场景,也可以实现对应的扩展函数:

fun AnnotatedString.trim(): AnnotatedString {
    val originalText = this.text
    val startIndex = originalText.indexOfFirst { !it.isWhitespace() }
    val endIndex = originalText.indexOfLast { !it.isWhitespace() }

    if (startIndex == -1) return AnnotatedString("")

    val newText = originalText.substring(startIndex, endIndex + 1)
    val offset = -startIndex // 修剪开头导致的索引偏移

    val adjustedSpanStyles = spanStyles.map { style ->
        style.copy(
            start = (style.start + offset).coerceAtLeast(0),
            end = (style.end + offset).coerceAtMost(newText.length)
        )
    }.filter { it.start < it.end }

    val adjustedParagraphStyles = paragraphStyles.map { style ->
        style.copy(
            start = (style.start + offset).coerceAtLeast(0),
            end = (style.end + offset).coerceAtMost(newText.length)
        )
    }.filter { it.start < it.end }

    return AnnotatedString(
        text = newText,
        spanStyles = adjustedSpanStyles,
        paragraphStyles = adjustedParagraphStyles
    )
}

内容的提问来源于stack exchange,提问作者BPDev

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.03 14:14:53