From c9b4a958bf81cea36c96b4d1632d6b626704d8d4 Mon Sep 17 00:00:00 2001 From: OneWay Date: Wed, 23 Sep 2026 09:30:00 +0800 Subject: [PATCH] fix(ui): merge article blocks to fix scroll jank Link-heavy articles split every line into its own list item, so a fast fling composed a dozen items in one frame and froze. Merge small blocks into multi-paragraph items: indent via em space and paragraph spacing via baseline shift. --- .../research/ui/article/ArticleScreen.kt | 86 ++++++++++++++++++- 1 file changed, 83 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/example/research/ui/article/ArticleScreen.kt b/app/src/main/java/com/example/research/ui/article/ArticleScreen.kt index c799827..ae89818 100644 --- a/app/src/main/java/com/example/research/ui/article/ArticleScreen.kt +++ b/app/src/main/java/com/example/research/ui/article/ArticleScreen.kt @@ -12,13 +12,20 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.unit.dp +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.BaselineShift import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.isUnspecified import androidx.compose.ui.unit.IntSize import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.example.research.R @@ -48,6 +55,72 @@ fun ArticleRoute( ) } +private const val MERGED_BLOCK_MAX_CHARS = 1500 +private const val MERGED_BLOCK_MAX_ITEMS = 12 + +private fun mergeDslBlocks( + blocks: List, + density: Density, + baseStyle: TextStyle, +): List { + if (blocks.size <= MERGED_BLOCK_MAX_ITEMS) return blocks + val gapShift = with(density) { + val fontPx = + if (baseStyle.fontSize.isUnspecified) 0f else baseStyle.fontSize.toPx() + if (fontPx > 0f) 8.dp.toPx() / (0.534f * fontPx) else 0.94f + } + val merged = ArrayList(blocks.size / 4) + var i = 0 + while (i < blocks.size) { + val builder = AnnotatedString.Builder() + val refs = ArrayList() + var j = i + while (j < blocks.size) { + val block = blocks[j] + repeat(block.indent) { builder.append(' ') } + val paraStart = builder.length + builder.append(block.text) + block.lazyRefs.forEach { ref -> + refs.add( + ref.copy( + startIndex = ref.startIndex + paraStart, + endIndex = ref.endIndex + paraStart + ) + ) + } + val more = j + 1 < blocks.size && + (j + 1 - i) < MERGED_BLOCK_MAX_ITEMS && + builder.length < MERGED_BLOCK_MAX_CHARS + if (more && !blocks[j + 1].continuesPrevious) { + val shiftPos = builder.length + builder.append('​') + builder.addStyle( + SpanStyle(baselineShift = BaselineShift(-gapShift)), + shiftPos, + shiftPos + 1 + ) + } + if (more) { + builder.append('\n') + } + j++ + if (!more) { + break + } + } + merged.add( + DslBlock( + text = builder.toAnnotatedString(), + indent = 0, + lazyRefs = refs, + continuesPrevious = blocks[i].continuesPrevious + ) + ) + i = j + } + return merged +} + @Composable fun ArticleContent( articleState: ArticleState?, @@ -123,6 +196,14 @@ fun ArticleContent( val isHeavyArticle = articleState.dslContent.size > HEAVY_ARTICLE_BLOCK_COUNT + val density = LocalDensity.current + val displayBlocks = remember( + articleState.dslContent, + density, + baseTextStyle + ) { + mergeDslBlocks(articleState.dslContent, density, baseTextStyle) + } val articleList: @Composable (Modifier) -> Unit = { listModifier -> LazyColumn( state = listState, @@ -134,15 +215,14 @@ fun ArticleContent( } ) { itemsIndexed( - items = articleState.dslContent, + items = displayBlocks, key = { index, _ -> - // Use stable key combining article save key and index "$articleSaveKey:$index" }, contentType = { _, _ -> "article_block" } ) { index, block -> val followedByContinuation = - articleState.dslContent.getOrNull(index + 1)?.continuesPrevious == true + displayBlocks.getOrNull(index + 1)?.continuesPrevious == true val textContent = @Composable { Box( modifier = Modifier