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.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<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
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