LazyColumn and LazyRow
Module 3 put Mandi Bhav's prices in a Column with verticalScroll and named the bill it would send later: every card is composed and laid out, including the ones nobody can see. Nine sample prices never made that bill arrive. A busy day at Vashi reports hundreds. This lesson measures the cost on the emulator with 500 prices, replaces the Column with a lazy layout that builds only what is on screen, and keeps everything modules 4 and 5 built on top of it: two columns from 600 dp, the commodity chips, and "Back to top".
Measuring the Column first
Never fix a performance problem you have not measured; you will not know whether you fixed it. Make a scratch copy of the project (module 5's checkpoint) and give it 500 prices by changing one line in PricesViewModel:
private val prices: List<CommodityPrice> = List(500) { i -> SamplePrices.all[i % SamplePrices.all.size] }
Then add a temporary log line as the first line of PriceCard's body, so you can count how many cards were composed:
android.util.Log.d("Lazy", "PriceCard composed: ${price.market} ${price.commodity}")
Two numbers matter. How long the app takes to show its first frame, which am start -W reports as TotalTime (module 1 met it); and how much memory the process holds, which dumpsys meminfo reports as TOTAL PSS in kilobytes. Force-stop the app before each launch so every run is a cold start:
adb shell am force-stop com.riztechacademy.mandibhav
adb shell am start -W -n com.riztechacademy.mandibhav/.MainActivity
adb shell dumpsys meminfo com.riztechacademy.mandibhav
The debug build you run from Android Studio is not what a user installs, and the Compose documentation says plainly that lazy layout performance can only be measured reliably in a release build with R8. So the runs for this lesson were done twice: on the debug build, and on a release build of the scratch copy, made installable by signing it with the debug key. In the scratch copy's app/build.gradle.kts only (never in the real project; the shipping module does release builds properly):
release {
applicationIdSuffix = ".m6rel"
signingConfig = signingConfigs.getByName("debug")
isMinifyEnabled = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
and ./gradlew installRelease. The results, on the course emulator (a 1080 × 2400 phone image running on a laptop), median of five debug runs and eight release runs:
| 500 prices | Cards composed at launch | First frame, debug | First frame, release | Memory (PSS), release |
|---|---|---|---|---|
Column + verticalScroll |
500 | 2,508 ms | 315 ms | 154,882 KB |
LazyColumn |
4 | 814 ms | 158 ms | 42,703 KB |
Read it honestly. The card count is exact, and it is the whole story: the Column built 500 cards to show four. The memory difference, about 110 MB, is real and is the one that hurts on a phone with 3 GB of RAM, where Android kills background apps to find memory. The start-up times are from an emulator running on a fast laptop: the gap in a release build is about 160 ms here, and I could not measure what it is on a real mid-range phone, which will be slower. The debug numbers are worth knowing for a different reason: they are why a screen can feel sluggish while you develop it and fine in the user's hands, or the other way round. Measure in a release build before you believe a number.
LazyColumn
A lazy layout composes only the items that are visible, plus a few beyond the edge of the screen that it prefetches, and disposes of items that scroll far away. You do not write a loop; you describe the items, and the layout decides when to run each description. This is the first cut that produced the numbers above, in place of the Column in PricesScreen:
LazyColumn(
modifier = Modifier
.padding(innerPadding)
.consumeWindowInsets(innerPadding)
.imePadding(),
contentPadding = PaddingValues(start = 16.dp, top = 8.dp, end = 16.dp, bottom = 88.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
PriceSearchField(
query = query,
onQueryChange = onQueryChange,
modifier = Modifier.fillMaxWidth(),
)
}
item {
CommodityChips(
commodities = commodities,
selected = selectedCommodity,
onCommodityClick = onCommodityClick,
)
}
items(prices) { price ->
PriceCard(price = price, modifier = Modifier.fillMaxWidth())
}
}
- The content lambda is not a composable lambda. It is a
LazyListScope, a small language for describing items.item { }adds one item;items(list) { }adds one per element. Theitemsthat takes aListis an extension function: importandroidx.compose.foundation.lazy.items, or the compiler offers you the version that takes a count. - The header is items too. The search field and the chips used to sit above the cards in the
Column; now they are the first two items, so they scroll away with the list as before. contentPaddingreplaces the padding afterverticalScroll. It pads the content, not the list: the first item starts 8 dp down, the last ends 88 dp up (clear of "Back to top"), and the list itself still fills the space.
Ten swipes from top to bottom of the 500 prices composed 86 cards in total: each card is composed when it comes on screen, not before.
The trap: keeping verticalScroll
The most common first attempt leaves the scroll where it was and puts a LazyColumn inside it. It compiles, and the app dies at launch:
java.lang.IllegalStateException: Vertically scrollable component was measured with an infinity maximum height constraints, which is disallowed. One of the common reasons is nesting layouts like LazyColumn and Column(Modifier.verticalScroll()).
A scrolling parent offers its child unlimited height, and a lazy list cannot decide which items are visible if it has no height. The fix is in the message: one scrolling container, with the header as items inside it. A lazy list inside a parent that scrolls the other way (a LazyRow in a LazyColumn) is fine.
Two columns: LazyVerticalGrid
Module 4 showed two columns from 600 dp by chunking the list into Rows. In a lazy layout the grid version is a component of its own, with the same items language. The decision about columns does not change; what it feeds does. The screen keeps a LazyGridState instead of a ScrollState:
val gridState = rememberLazyGridState()
val layoutDirection = LocalLayoutDirection.current
and the grid replaces the LazyColumn:
LazyVerticalGrid(
columns = GridCells.Fixed(columns),
state = gridState,
// The top and sides go outside the scroll, so the sticky chips stop
// under the app bar. The bottom goes inside it: cards scroll behind
// the gesture handle, and the last one still ends clear of it.
modifier = Modifier
.padding(
start = innerPadding.calculateStartPadding(layoutDirection),
top = innerPadding.calculateTopPadding(),
end = innerPadding.calculateEndPadding(layoutDirection),
)
.consumeWindowInsets(innerPadding)
.imePadding(),
// 88 dp more at the bottom lets the last card scroll clear of the
// "Back to top" button (56 dp tall, 16 dp from the edge).
contentPadding = PaddingValues(
start = 16.dp,
top = 8.dp,
end = 16.dp,
bottom = innerPadding.calculateBottomPadding() + 88.dp,
),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
item(span = { GridItemSpan(maxLineSpan) }) {
PriceSearchField(
query = query,
onQueryChange = onQueryChange,
modifier = Modifier.fillMaxWidth(),
)
}
stickyHeader {
// A surface behind the chips, so cards do not show through
// when they scroll underneath.
Surface(modifier = Modifier.fillMaxWidth()) {
CommodityChips(
commodities = commodities,
selected = selectedCommodity,
onCommodityClick = { commodity ->
onCommodityClick(commodity)
// An event, so it is handled here, not in an effect:
// a new filter starts from its first result.
scope.launch { gridState.scrollToItem(0) }
},
)
}
}
if (noResults) {
item(span = { GridItemSpan(maxLineSpan) }) {
Text(
text = "No prices match \"${query.trim()}\".",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
items(prices) { price ->
PriceCard(price = price, modifier = Modifier.fillMaxWidth())
}
}
What each new piece is for:
GridCells.Fixed(columns)gives exactly one or two columns, from the sameisWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)decision as module 4. One code path for both, as module 4 asked. (GridCells.Adaptive(minSize)fits as many columns as there is room for; you would lose the 600 dp rule.)span = { GridItemSpan(maxLineSpan) }makes the search field and the "No prices match" message as wide as the whole grid, not one column.stickyHeaderkeeps the chips at the top once they reach it, so a farmer far down the list can still change the commodity. TheSurfacegives them a background; without it the cards show through the gaps between chips as they scroll underneath.scrollToItem(0)in the chip's click. Module 5 tried scrolling to the top when the filter changed, found that aLaunchedEffectthrew away the scroll position on rotation, and that the chips scrolled off screen anyway, and asked the question again for this module. Sticky chips answer the second problem, and doing it in the click handler, an event, answers the first.- The insets, as module 4 promised. Module 4 applied
innerPaddingoutside the scroll, so the cards stopped above the gesture handle. Now the bottom inset is part ofcontentPadding, so cards scroll behind the handle and the last one still ends above it. The top stays outside, because a sticky header sticks to the top of the list, and the list should begin under the app bar.
New imports, all from androidx.compose.foundation.lazy.grid unless noted: LazyVerticalGrid, GridCells, GridItemSpan, items, rememberLazyGridState; calculateStartPadding and calculateEndPadding from androidx.compose.foundation.layout; LocalLayoutDirection from androidx.compose.ui.platform; Surface from androidx.compose.material3. IntrinsicSize, Row, Spacer, fillMaxHeight, height, rememberScrollState, verticalScroll and LocalDensity are no longer used in this file.
What the grid cost: equal heights
Module 4 made both cards in a row the same height with IntrinsicSize.Min. The grid does not do that, and adding fillMaxHeight() to the card does nothing: each cell is as tall as its own card. At the 1.5× font scale from module 4's accessibility lesson, on the 701 dp foldable size, "Vashi APMC, Navi Mumbai" wrapped and the Wheat card stood visibly taller than the Soybean card beside it. Module 4 also warned that intrinsics across a whole long list cost a measuring pass per row. Mandi Bhav accepts ragged rows in exchange for a lazy grid. The alternative is a LazyColumn of chunked Rows, which keeps equal heights but makes each item a pair of prices, which matters in the next lesson.
LazyRow for the chips
Seven chips wrapped onto two lines in a FlowRow, and on a phone those two lines of a sticky header eat into the cards. In FilterControls.kt, CommodityChips becomes one line that scrolls sideways:
LazyRow(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
item(key = "all") {
FilterChip(
selected = selected == null,
onClick = { onCommodityClick(null) },
label = { Text("All") },
)
}
items(items = commodities, key = { it }) { commodity ->
FilterChip(
selected = selected == commodity,
onClick = { onCommodityClick(commodity) },
label = { Text(commodity) },
)
}
}
The imports change from FlowRow to androidx.compose.foundation.lazy.LazyRow and androidx.compose.foundation.lazy.items. The key arguments are the next lesson's subject. What it costs: on a phone, "Soybean" and "Tur dal" are off the right edge until you swipe, and nothing on screen says so except the cut-off chip. For seven commodities that is acceptable; for forty, a search or a dropdown would serve better.
Back to top, on the grid's state
ScrollState.value was pixels; a lazy layout cannot tell you how many pixels down you are, because it has not measured the items above. It tells you which item is first on screen instead:
// The index of the first visible item changes a few times per screenful,
// not on every pixel; derivedStateOf still turns it into one yes or no.
val showBackToTop by remember {
derivedStateOf { gridState.firstVisibleItemIndex > BACK_TO_TOP_AFTER_ITEMS }
}
with, at the bottom of the file:
/** Show "Back to top" once the search field and the first two cards have scrolled away. */
private const val BACK_TO_TOP_AFTER_ITEMS = 3
and the button calls gridState.animateScrollToItem(0) instead of scrollState.animateScrollTo(0). Module 5's derivedStateOf reasoning still holds, because the state object changes on every scroll frame even when the index does not.
| You have | State | Remember it with | Scroll it with |
|---|---|---|---|
Column + verticalScroll |
ScrollState |
rememberScrollState() |
animateScrollTo(px) |
LazyColumn / LazyRow |
LazyListState |
rememberLazyListState() |
animateScrollToItem(index) |
LazyVerticalGrid |
LazyGridState |
rememberLazyGridState() |
animateScrollToItem(index) |
All three are saved for you across rotation and process death.
Check your work
What the Column cost, exactly. 500 cards composed to show four, and about 110 MB more memory in a release build.
Why the debug numbers are not the ones to quote. Debug builds of Compose are much slower than release builds with R8; the documentation says lazy layout performance is only reliable in release.
What items(prices) { } is. A description of one item per price, run by the layout when that item is needed.
Why LazyColumn inside verticalScroll crashes. The scrolling parent gives it unlimited height, so it cannot work out which items are visible.
What GridItemSpan(maxLineSpan) does. Makes an item span every column.
Why the bottom inset went into contentPadding and the top did not. So cards scroll behind the gesture handle; and so the sticky chips stop under the app bar.
What the grid gave up. Equal card heights in a row.
Practice 4. It crashes at launch with the IllegalStateException above. A fixed height (Modifier.height(400.dp)) on the LazyColumn stops the crash, and gives you a 400 dp window scrolling inside a page that also scrolls, which nobody wants on a phone.
Practice
- Make the 500-price scratch copy and measure the
Columnand theLazyColumnyourself, debug and release, five launches each. Write your medians next to the table above; yours will differ, and that is the point of measuring. - Scroll the 500-price
LazyColumnfrom top to bottom and count the "PriceCard composed" lines. Then scroll back up and count again. Explain why scrolling back composes cards again. - Build the grid, the
LazyRowchips and the sticky header in the real project. Check one column on the phone, two in landscape, and the chips staying put as you scroll. - Break it on purpose: wrap the
LazyColumnin the scratch copy inverticalScroll(rememberScrollState()), run it, and read the exception. Then give theLazyColumna fixed height and describe what you get. - At 1.5× font scale and
adb shell wm size 1840x2208, find two cards of different heights in one row. Thenadb shell wm size resetand put the font scale back. - Harder: rebuild the two-column layout as a
LazyColumnwhose items areprices.chunked(columns), each aRowwithIntrinsicSize.Minas in module 4. Confirm equal heights come back, then write down what an item now is, and what goes wrong if one price in a pair changes.
Official documentation
- Lists and grids — Every lazy layout, spans, sticky headers, scroll state, and the note on measuring in release builds.
- Follow best practices — Lazy layout keys,
derivedStateOffor scroll position, and other performance rules. - About window insets — Passing insets into
contentPaddingso content draws behind the system bars. - Overview of measuring app performance — Why not to measure a debug build, start-up targets, and the tools the profiling module uses.
Next: keys, and the bug where a tap lands on the wrong price.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Selected students who finish a course can join our internship, with real-world projects and code review from a working engineer.
About the internship