Loading...
Loading...
Use this skill to fix scroll jank, lost item state, and broken animateItem() animations in LazyColumn, LazyRow, LazyVerticalGrid, and LazyHorizontalGrid. Covers stable item keys, contentType for mixed-type feeds, Modifier.animateItem() requirements, hoisting modifier chains and painters out of the items lambda, and validating item composable stability. Use when the developer mentions LazyColumn jank, dropped frames while scrolling, items losing scroll state on insert/remove/reorder, mixed feeds of cards/headers/ads feeling sluggish, animateItem() not animating, RecyclerView view-type analog, key parameter, or contentType parameter. The prefetch-window tuning lives in a sibling skill.
npx skill4agent add skydoves/compose-performance-skills optimizing-lazy-layoutskeycontentTypeitems { }../configuring-lazy-prefetch/SKILL.mdLazyColumnLazyRowLazyVerticalGridLazyHorizontalGridModifier.animateItem()unstable@TraceRecomposition../configuring-lazy-prefetch/SKILL.mdList<Foo>Flow<Foo>var../../stability/diagnosing-compose-stability/SKILL.md../../stability/stabilizing-compose-types/SKILL.mdstate.value../../recomposition/deferring-state-reads/SKILL.mdfirstVisibleItemIndex == 0../../recomposition/choosing-derivedstateof/SKILL.mdModifier.animateItem()animateItemPlacementorg.jetbrains.kotlin.plugin.compose../../measurement/generating-baseline-profiles/SKILL.mditems(...)LazyListScope.items(list)items(count)itemsIndexed(list)LazyGridScopekey = { it.id }UUID.randomUUID()hashCode()// WRONG
LazyColumn { items(snacks) { snack -> SnackRow(snack) } }
// WRONG because: index-based identity → insert/remove discards composition state and breaks animateItem().// RIGHT
LazyColumn {
items(
items = snacks,
key = { it.id },
contentType = { it::class },
) { snack ->
SnackRow(snack, Modifier.animateItem())
}
}contentTypecontentTypecontentType../../stability/diagnosing-compose-stability/SKILL.mdunstablekeycontentType../../stability/stabilizing-compose-types/SKILL.mdBorderStrokerememberLazyColumnModifierModifier.animateItem()keykeyfadeInSpecfadeOutSpecplacementSpecpainterResource(...)MaterialTheme.colorScheme.surfaceRoundedCornerShape(...)rememberLazyColumn@TraceRecompositionkey// WRONG
LazyColumn {
items(snacks) { snack -> SnackRow(snack) }
}
// WRONG because: items default to index-based identity. On insert/remove/reorder, every position past the change point has a different "identity", composition state and scroll-restoration are lost, and Modifier.animateItem() has nothing to animate from.// RIGHT
LazyColumn {
items(snacks, key = { it.id }) { snack -> SnackRow(snack) }
}// WRONG
items(snacks, key = { UUID.randomUUID() }) { snack -> SnackRow(snack) }
// WRONG because: a fresh key on every recomposition guarantees the cached composition is discarded every time — strictly worse than no key.// WRONG
items(snacks, key = { it.hashCode() }) { snack -> SnackRow(snack) }
// WRONG because: hashCode() of a mutable type changes when fields mutate, breaking identity continuity for the same logical item.// RIGHT
items(snacks, key = { it.id }) { snack -> SnackRow(snack) }contentType// WRONG
items(feed, key = { it.id }) { item ->
when (item) {
is FeedItem.Card -> CardRow(item)
is FeedItem.Ad -> AdRow(item)
is FeedItem.Header -> HeaderRow(item)
}
}
// WRONG because: cached compositions of one type are discarded when scrolled into a different type's slot — every row crossing a type boundary is a fresh build instead of a recycled update.// RIGHT
items(
items = feed,
key = { it.id },
contentType = { it::class },
) { item ->
when (item) {
is FeedItem.Card -> CardRow(item)
is FeedItem.Ad -> AdRow(item)
is FeedItem.Header -> HeaderRow(item)
}
}Modifier.animateItem()// WRONG
items(snacks) { snack ->
SnackRow(snack, Modifier.animateItem())
}
// WRONG because: animateItem() binds animation state to the item's key. With no key, identity is index-based, so an insert at position 0 looks like every-row-changed and nothing animates correctly.// RIGHT
items(snacks, key = { it.id }) { snack ->
SnackRow(snack, Modifier.animateItem())
}// WRONG
items(snacks, key = { it.id }) { snack ->
val placeholder = painterResource(R.drawable.snack_placeholder)
val border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline)
Card(border = border) {
AsyncImage(snack.imageUrl, placeholder = placeholder)
}
}
// WRONG because: painterResource resolution and BorderStroke allocation happen on every item composition; at high scroll velocity these compound into measurable allocation pressure.// RIGHT
@Composable
fun SnackList(snacks: ImmutableList<Snack>) {
val placeholder = painterResource(R.drawable.snack_placeholder)
val border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline)
LazyColumn {
items(snacks, key = { it.id }, contentType = { it::class }) { snack ->
Card(border = border) {
AsyncImage(snack.imageUrl, placeholder = placeholder)
}
}
}
}ModifierModifier.fillMaxWidth().padding(16.dp)remember { Modifier.… }// WRONG
@Composable
fun SnackRow(snack: Snack, tags: List<String>) { /* ... */ }
// Caller:
items(snacks, key = { it.id }) { snack ->
SnackRow(snack, tags = snack.tags)
}
// WRONG because: List<String> is an unstable parameter under inference; every scroll-driven recomposition recomposes the row body even though the snack didn't change.// RIGHT
@Immutable
data class Snack(val id: Long, val name: String, val tags: ImmutableList<String>)
@Composable
fun SnackRow(snack: Snack) { /* ... */ }
items(snacks, key = { it.id }, contentType = { it::class }) { snack ->
SnackRow(snack)
}../../stability/stabilizing-compose-types/SKILL.mdLazyVerticalGrid// RIGHT — keys + contentType apply to grids identically
LazyVerticalGrid(columns = GridCells.Fixed(2)) {
items(
items = feed,
key = { it.id },
contentType = { it::class },
span = { item -> if (item is FeedItem.Header) GridItemSpan(maxLineSpan) else GridItemSpan(1) },
) { item ->
when (item) {
is FeedItem.Header -> HeaderRow(item, Modifier.animateItem())
is FeedItem.Card -> CardCell(item, Modifier.animateItem())
}
}
}keyitems(...)UUID.randomUUID()hashCode()contentTypeit::classenumModifier.animateItem()key../../stability/diagnosing-compose-stability/SKILL.mdkeycontentTypeitems { }Row { items { } }RowColumnBox../configuring-lazy-prefetch/SKILL.mdModifier.animateItem()@TraceRecompositioncomposables.txtrestartable skippablestableruntime