如何使用正则解析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
相关产品推荐
相关产品推荐

