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.
This commit is contained in:
OneWay committed 2026-09-23 09:30:00 +08:00
1 parent 71fea4472b
commit c9b4a958bf
1 file changed
+83 -3
@@ -12,13 +12,20 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.unit.dp 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.text.style.TextOverflow
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.isUnspecified
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.example.research.R 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<DslBlock>,
density: Density,
baseStyle: TextStyle,
): List<DslBlock> {
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<DslBlock>(blocks.size / 4)
var i = 0
while (i < blocks.size) {
val builder = AnnotatedString.Builder()
val refs = ArrayList<DslAnnotatedParser.LazyRef>()
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 @Composable
fun ArticleContent( fun ArticleContent(
articleState: ArticleState?, articleState: ArticleState?,
@@ -123,6 +196,14 @@ fun ArticleContent(
val isHeavyArticle = val isHeavyArticle =
articleState.dslContent.size > HEAVY_ARTICLE_BLOCK_COUNT 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 -> val articleList: @Composable (Modifier) -> Unit = { listModifier ->
LazyColumn( LazyColumn(
state = listState, state = listState,
@@ -134,15 +215,14 @@ fun ArticleContent(
} }
) { ) {
itemsIndexed( itemsIndexed(
items = articleState.dslContent, items = displayBlocks,
key = { index, _ -> key = { index, _ ->
// Use stable key combining article save key and index
"$articleSaveKey:$index" "$articleSaveKey:$index"
}, },
contentType = { _, _ -> "article_block" } contentType = { _, _ -> "article_block" }
) { index, block -> ) { index, block ->
val followedByContinuation = val followedByContinuation =
articleState.dslContent.getOrNull(index + 1)?.continuesPrevious == true displayBlocks.getOrNull(index + 1)?.continuesPrevious == true
val textContent = @Composable { val textContent = @Composable {
Box( Box(
modifier = Modifier modifier = Modifier