Compose Layouts, Modifiers and Lazy Lists
|
This content was generated with the assistance of AI and should be verified against the official documentation of Android before being relied on in production. See the section bibliography for the reference material consulted while preparing these pages. |
This page covers Compose’s layout system in depth: the three basic layout composables (Row, Column, Box),
the modifier system that decorates and constrains any composable, and the lazy list and grid APIs that scroll
large collections without keeping every item’s composition alive — the Compose counterpart to
RecyclerView. It targets this section’s baseline, Compose BOM
2026.09.00 (androidx.compose.foundation/ui/material3 1.12.1/1.12.1/1.4.0), and builds on
Jetpack Compose Fundamentals, which introduces @Composable
functions, recomposition and the Compose mental model assumed throughout. State management, remember and side
effects are covered separately on
State, Recomposition and Side Effects in Compose; this
page treats state as already available and focuses only on how it is laid out on screen. The running example is
TrailMate’s trail list, built here into the full TrailListScreen with its TrailCard row — the direct Compose
analog of the TrailListFragment/RecyclerView/TrailAdapter combination from
RecyclerView and Lists.
Row, Column and Box
Compose has no XML layout files and no ViewGroup subclasses; every layout is a @Composable function that
measures and places its children. Three cover most needs:
-
Rowplaces its children left to right (or right to left, mirrored automatically in a right-to-left locale) along a single horizontal line. -
Columnplaces its children top to bottom along a single vertical line. -
Boxstacks its children on top of one another, in composition order, each positioned independently within the box’s bounds.
TrailCard below uses all three: a Row for the thumbnail-and-text layout, a Column inside it for the
stacked name/region/length text, and a Box around the thumbnail so a difficulty badge can overlay it.
Arrangement and Alignment
Row and Column take an Arrangement for how children are spaced along the main axis (horizontal for Row,
vertical for Column) and an Alignment for how they are positioned along the cross axis:
// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailCard.kt
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(16.dp),
) {
// thumbnail, then text column
}
Arrangement.spacedBy(12.dp) puts a fixed 12dp gap between children without a Spacer between each one;
Arrangement.SpaceBetween, Arrangement.SpaceEvenly and Arrangement.SpaceAround distribute leftover space
instead of using a fixed gap, and Arrangement.Center/Arrangement.Start/Arrangement.End (or Top/Bottom
for a Column) group children together at one end or the middle. Alignment.CenterVertically (a Row’s
cross-axis alignment type is `Alignment.Vertical; a Column’s is `Alignment.Horizontal) centers each child on
the row’s height; Box instead takes a single contentAlignment (Alignment, both axes at once, e.g.
Alignment.BottomEnd) applied to every child that does not override it with its own Modifier.align(). See
Android Developers — Layouts in Compose
(Arrangements and Alignment sections).
weight()
Modifier.weight(1f), available only inside a RowScope or ColumnScope, tells the parent to give that child a
share of the remaining space along the main axis proportional to its weight, after every non-weighted sibling
has already been measured at its own preferred size — the Compose equivalent of LinearLayout’s
`layout_weight. `TrailCard’s text column takes the space left over after the fixed-size thumbnail:
Row(modifier = Modifier.fillMaxWidth()) {
TrailThumbnail(trail.photoUrl, modifier = Modifier.size(64.dp))
Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
Text(trail.name, style = MaterialTheme.typography.titleMedium)
Text(trail.region, style = MaterialTheme.typography.bodySmall)
}
}
weight(1f, fill = false) lets a weighted child be smaller than its share instead of stretching to fill it — useful for a weighted Text that should wrap and shrink rather than pad itself with blank space. Two children
each weighted 1f split the remaining space evenly; weighting one child 2f and another 1f gives the first
twice the second’s share. See
Android Developers — Layouts in Compose
(weight section).
Modifiers, and Why Order Matters
A Modifier decorates or constrains a single composable: padding, size, background, click handling, clipping and
dozens more are all modifier functions, chained with . into a single Modifier value passed as a composable’s
modifier parameter. Unlike a View’s set of independent XML attributes, a Modifier chain is an ordered
sequence of wrappers, each one applied to the element produced by the modifier before it — which is why the
following two chains produce visibly different results even though they name the same three modifiers:
// padding first, then background: the background is drawn INSIDE the padding
Modifier
.padding(16.dp)
.background(Color.LightGray)
.clickable { onClick() }
// background first, then padding: the background fills the full element, padding shrinks the content within it
Modifier
.background(Color.LightGray)
.padding(16.dp)
.clickable { onClick() }
In the first chain, padding(16.dp) shrinks the space available to everything that follows it, so
background(Color.LightGray) only paints the smaller, already-padded area, leaving a transparent 16dp margin
around a gray box. In the second chain, background() runs first and paints the entire incoming size, and
padding() afterward only shrinks the content measured and placed inside that already-gray box — no visible
margin, a larger gray area. clickable()’s position in the chain matters the same way: placed before `padding(),
the padding itself is part of the clickable area; placed after, taps that land only in the padding are ignored.
There is no universal "correct" order — it depends on which look is wanted — but the ordering is never
arbitrary, and reading a modifier chain top to bottom as "wrap, then wrap again" (not as a bag of independent
attributes) is the mental model that avoids surprises. See
Android Developers — Compose Modifiers (Order
matters section).
A modifier chain is also reusable data — it can be built once and reused, or passed down and extended by a
caller, which is why every well-behaved composable accepts a modifier: Modifier = Modifier parameter and
applies it to its own outermost element (typically first in that composable’s own chain, ahead of modifiers the
composable adds itself), letting a caller add its own padding, size or testTag without the composable’s author
anticipating every use. TrailCard below follows this convention.
Common Modifiers
| Modifier | Purpose |
|---|---|
|
Fixes an exact size (or one dimension), overriding what the child would otherwise measure itself as. |
|
Grows to fill the available space in one or both dimensions, optionally scaled with a |
|
Adds inset space around the content, shrinking what is available to it — see the ordering discussion above. |
|
Paints a solid color (or |
|
Clips the element’s drawing (and touch target) to a |
|
Makes the element focusable and clickable, drawing the platform’s default ripple and announcing itself to
accessibility services as clickable — always prefer this over a raw |
|
Shares remaining main-axis space inside a |
|
Overrides a single child’s alignment inside a |
|
Attaches a stable identifier for Compose UI tests — see Instrumented and UI Testing (Espresso, Compose Testing, UI Automator). |
See Android Developers — Compose Modifiers for the full list.
Custom Modifiers with Modifier.Node
Most apps only compose existing modifiers, but a modifier that needs to hook into measurement, drawing, pointer
input, or a node’s own lifecycle directly is written as a Modifier.Node — the low-level, allocation-efficient
API the built-in modifiers (padding, clickable, drawBehind, …) are themselves implemented on top of. A
Modifier.Node implementation is paired with a ModifierNodeElement that Compose uses to create, update and
reuse node instances across recompositions without discarding them, which is why it should be preferred over the
older, function-based Modifier.composed { } (deprecated) for any modifier that carries internal state:
// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/FavoriteMarkerModifier.kt
private class FavoriteMarkerNode(var color: Color) : DrawModifierNode, Modifier.Node() {
override fun ContentDrawScope.draw() {
drawContent()
drawCircle(color = color, radius = 6.dp.toPx(), center = Offset(size.width - 12.dp.toPx(), 12.dp.toPx()))
}
}
private data class FavoriteMarkerElement(val color: Color) : ModifierNodeElement<FavoriteMarkerNode>() {
override fun create() = FavoriteMarkerNode(color)
override fun update(node: FavoriteMarkerNode) {
node.color = color
}
}
fun Modifier.favoriteMarker(color: Color): Modifier = this then FavoriteMarkerElement(color)
DrawModifierNode is one of several node interfaces a Modifier.Node can implement (others include
LayoutModifierNode for custom measurement, PointerInputModifierNode for touch handling, and
GlobalPositionAwareModifierNode for reacting to a layout’s position on screen); a single node class can
implement more than one when a modifier needs to both measure and draw. update() runs instead of create()
when an equivalent element is already attached, letting Compose reuse the same node instance (and whatever
internal state it holds) across recompositions instead of tearing it down and recreating it every time
favoriteMarker() is called with a new color. See
Android Developers — Create a Custom
Modifier Using Modifier.Node (fetched directly from developer.android.com/develop/ui/compose, since it is not
yet in this section’s pre-verified concept map).
Surface and Card
Surface, from Material 3, is the building block behind most Material components: it draws a background color
(defaulting to MaterialTheme.colorScheme.surface), an optional border, an optional shadow driven by
tonalElevation/shadowElevation, clips its content to a shape, and — when given an onClick — makes the
whole surface a single ripple-drawing click target with correct semantics. Card is Surface pre-configured
with sensible defaults (a RoundedCornerShape, a small default elevation) for the common "card" look:
// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailCard.kt
Card(
onClick = { onTrailClick(trail) },
modifier = modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
) {
// Row/Column content, see the full TrailListScreen below
}
Reaching for Surface directly (instead of Box plus a manual background()/clip()/clickable() chain) is
preferred whenever the element should look and behave like a Material surface, since it also participates
correctly in Material’s elevation-based tonal color system in dark theme — covered on
Material 3 and Theming in Compose. See
Android Developers — Material Components in
Compose.
ConstraintLayout for Compose
Row/Column/Box compose naturally for most screens, but a layout with many constraints relative to each
other — especially one that must adapt its arrangement per window size without deeply nesting Row`s inside
`Column`s inside `Box`es — benefits from `ConstraintLayout, available in Compose from the separate
androidx.constraintlayout:constraintlayout-compose artifact. Each child is given a Modifier.constrainAs(ref) {
} block that positions it relative to other children’s `ConstrainedLayoutReference`s or to the parent:
ConstraintLayout(modifier = Modifier.fillMaxWidth()) {
val (thumbnail, name, badge) = createRefs()
TrailThumbnail(
trail.photoUrl,
modifier = Modifier.constrainAs(thumbnail) {
top.linkTo(parent.top)
start.linkTo(parent.start)
},
)
Text(
trail.name,
modifier = Modifier.constrainAs(name) {
top.linkTo(thumbnail.top)
start.linkTo(thumbnail.end, margin = 12.dp)
},
)
DifficultyBadge(
trail.difficulty,
modifier = Modifier.constrainAs(badge) {
top.linkTo(thumbnail.top)
end.linkTo(parent.end)
},
)
}
This mirrors the View-world ConstraintLayout covered on
Views, XML Layouts, View Binding and Material Components, down to the
same relative-constraint vocabulary, but is written in Kotlin instead of an XML constraint set, and a Modifier
replaces app:layout_constraint* attributes. TrailMate’s own TrailCard does not need it — Row/Column
alone express its layout — and ConstraintLayout is reached for only when constraint-based positioning
genuinely simplifies a screen over nested Row/Column/Box, since it also carries a small extra measurement
cost over the plain layout composables. See
Android Developers — Constraintlayout
in Compose.
FlowRow and BoxWithConstraints
FlowRow (and its vertical counterpart FlowColumn), from androidx.compose.foundation.layout, lays children
out like a Row but wraps overflow onto additional rows instead of clipping or overflowing horizontally — useful for TrailMate’s trail-detail tag chips (difficulty, region, "dog friendly", "shaded") whose count varies
per trail:
// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailTagRow.kt
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
trail.tags.forEach { tag -> AssistChip(onClick = {}, label = { Text(tag) }) }
}
maxItemsInEachRow caps how many children a row holds before wrapping, regardless of remaining width.
BoxWithConstraints is a different tool for a related problem: it exposes the Constraints (as minWidth,
maxWidth, minHeight, maxHeight, plus Dp-converted equivalents) that its parent actually passed down,
letting content branch on the space it was given — TrailMate’s TrailDetailScreen uses it to switch its photo
gallery between a single column and a two-column grid depending on the available width, which is a per-composable
alternative to reading a window size class:
BoxWithConstraints {
if (maxWidth < 400.dp) {
PhotoColumn(photos)
} else {
PhotoGrid(photos, columns = 2)
}
}
BoxWithConstraints forces an extra layout pass compared to Box, since it must be measured before its content
can decide what to compose, so it is used sparingly and window size classes (covered on
Adaptive Layouts, Edge-to-Edge and Large
Screens) are preferred for whole-screen decisions. See
Android Developers — Use Flow Layouts in Compose.
Lazy Lists and Grids
Composing every row of a long list up front — as a plain Column inside a Modifier.verticalScroll() would — keeps every row’s composition, layout and drawing state alive even while off screen, which does not scale past a
short list. LazyColumn (and horizontal LazyRow) solves the same problem RecyclerView solves in the View
world, covered on RecyclerView and Lists: it composes and lays out
only the items currently visible (plus a small prefetch margin), and reuses the composition of an item that
scrolls off screen for a new item that scrolls on, instead of recreating it from scratch.
The RecyclerView page diagrams how RecyclerView splits that job between a LayoutManager, an Adapter, `ViewHolder`s and a recycled view pool.
LazyColumn has no Adapter/ViewHolder/LayoutManager split — a single LazyListScope builder describes the
content declaratively — but the underlying idea (measure and compose only what is visible, recycle the rest) is
the same one behind RecyclerView; LazyColumn is Compose’s opinionated, single-column-or-row
implementation of it, with LazyVerticalGrid/LazyHorizontalGrid and the staggered variants below covering the
grid cases GridLayoutManager/StaggeredGridLayoutManager cover for RecyclerView.
// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListScreen.kt
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(
items = uiState.trails,
key = { trail -> trail.id },
contentType = { "trail" },
) { trail ->
TrailCard(
trail = trail,
onTrailClick = onTrailClick,
onFavoriteClick = onFavoriteClick,
modifier = Modifier.animateItem(),
)
}
}
items() (and the single-item item(), for content that appears once, such as a header) is the builder function
that emits a range of the LazyListScope’s content; `LazyColumn/LazyRow compose only the items() calls whose
resulting items currently fall within the visible range (plus prefetch), not the whole list up front.
key, contentType and animateItem()
Three parameters materially change how a lazy list behaves, and skipping them is a common source of subtle bugs and lost performance:
-
keygives each item a stable identity across recompositions, independent of its position —trail.idabove, never the loop index. Without akey, Compose defaults to using the item’s position as its implicit identity, so inserting a trail at the top of the list makes Compose think every existing item’s content changed (since the same position now holds a different trail), discarding scroll-linked state such asrememberblocks and animations that akey-based identity would have preserved by recognizing the item as merely having moved. -
contentTypetells the lazy layout which items can share the same composition slot when they scroll off and a different item scrolls on. Two items with the samecontentTypereuse each other’s already-composed slot — skipping the more expensive full recomposition a mismatched type would force — which matters once a list mixes shapes, e.g. TrailMate’s onboarding-tip header (contentType = "header") interleaved with regular trail rows (contentType = "trail"). -
Modifier.animateItem(), applied inside the item content as inTrailCardabove, animates an item when it is inserted, removed or moved — the Compose analog ofRecyclerView’s `ItemAnimator. Since Compose 1.7 it takes three specs:fadeInSpec(the item appearing),placementSpec(the item sliding to a new position because items were inserted, removed or reordered around it) andfadeOutSpec(the item disappearing); each defaults to a spring and can be set tonullto turn that part off. The specs are covered on Compose Animation and Gestures.
Getting key wrong is the single most common lazy-list mistake: a key that is not actually stable (derived from
mutable state, or recomputed per recomposition) defeats its own purpose, and a key that collides between two
different items (two trails sharing an id due to a data bug) crashes with a duplicate-key exception at runtime.
See Android Developers — Lists and Grids (Item keys and
Content type sections).
Grids, Including Staggered Grids
LazyVerticalGrid/LazyHorizontalGrid lay items into a fixed number of columns (or rows), specified with
GridCells.Fixed(count) for an exact column count or GridCells.Adaptive(minSize) for as many columns as fit
at least minSize wide — the latter is what TrailMate’s photo gallery on PhotoCaptureScreen uses, so the
column count itself adapts across phone and tablet widths without a separate window-size-class branch:
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 96.dp),
contentPadding = PaddingValues(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(photos, key = { it.uri }) { photo -> PhotoThumbnail(photo) }
}
LazyVerticalStaggeredGrid/LazyHorizontalStaggeredGrid, from the same foundation artifact, are the grid
counterpart of StaggeredGridLayoutManager: each item keeps its own natural height (or width, for the horizontal
variant) instead of being forced to a uniform row height, so photos of different aspect ratios pack without
letterboxing:
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(2),
verticalItemSpacing = 8.dp,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
items(photos, key = { it.uri }) { photo -> PhotoThumbnail(photo) }
}
See Android Developers — Lists and Grids (Grid lists section).
Sticky Headers
LazyListScope.stickyHeader { } marks one item as a header that stays pinned to the top of the viewport while
its section’s items scroll underneath it, until the next stickyHeader pushes it off — useful for TrailMate’s
region-grouped trail browser (a stickyHeader per region name, e.g. "Sierra Nevada", above that region’s
trails):
LazyColumn {
trailsByRegion.forEach { (region, trails) ->
stickyHeader(key = region) {
RegionHeader(region, modifier = Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surface))
}
items(trails, key = { it.id }) { trail -> TrailCard(trail, onTrailClick, onFavoriteClick) }
}
}
stickyHeader() is part of androidx.compose.foundation.lazy (no separate artifact needed), and giving the
header its own opaque background() matters — without one, the trail rows scrolling underneath show through the
pinned header. See Android Developers — Lists and Grids
(Sticky headers section).
LazyListState
rememberLazyListState() creates a LazyListState that both reports the list’s current scroll position and lets
code drive it programmatically; passed to LazyColumn(state = ), it exposes firstVisibleItemIndex,
firstVisibleItemScrollOffset, and suspend functions scrollToItem(index) (jumps instantly) and
animateScrollToItem(index) (animates the scroll):
val listState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()
LazyColumn(state = listState) { /* … */ }
Button(onClick = { coroutineScope.launch { listState.animateScrollToItem(0) } }) {
Text("Back to top")
}
listState.layoutInfo.visibleItemsInfo gives lower-level access to exactly which items are visible and their
pixel offsets, which TrailMate uses to show a "scroll to top" button only once the list has been scrolled past its
first screen. Reading firstVisibleItemIndex inside the composable body (rather than only inside a callback)
makes that composable recompose on every scroll frame, so deriving a boolean from it with derivedStateOf — covered on State, Recomposition and Side Effects in
Compose — avoids recomposing more than necessary. LazyListState is also what `rememberSaveable`s the scroll
position across configuration changes and process death by default. See
Android Developers — Lists and Grids (Controlling the
scroll position section).
PullToRefreshBox
PullToRefreshBox, from androidx.compose.material3.pulltorefresh (part of the Material 3 artifact), wraps
scrollable content and shows the Material pull-to-refresh spinner while a manual pull gesture (or a
programmatically driven isRefreshing) is in progress:
PullToRefreshBox(
isRefreshing = uiState.isRefreshing,
onRefresh = { viewModel.refresh() },
modifier = Modifier.fillMaxSize(),
) {
LazyColumn { /* … */ }
}
isRefreshing is driven by TrailMate’s TrailListViewModel, not by the component itself — PullToRefreshBox
only detects the pull gesture and calls onRefresh(); the caller decides when the refresh actually finishes and
sets isRefreshing back to false. See
Android Developers — Material Components in
Compose (fetched directly from developer.android.com/develop/ui/compose/components, since PullToRefreshBox is
recent enough that it is not broken out separately in this section’s pre-verified concept map).
HorizontalPager
HorizontalPager (and VerticalPager), from androidx.compose.foundation.pager, is Compose’s counterpart to
ViewPager2 covered on RecyclerView and Lists: it snaps to exactly
one full-width page at a time, driven by a rememberPagerState(pageCount = ):
// feature/onboarding/src/main/kotlin/com/example/trailmate/feature/onboarding/OnboardingScreen.kt
val pagerState = rememberPagerState(pageCount = { onboardingPages.size })
Column {
HorizontalPager(state = pagerState, modifier = Modifier.weight(1f)) { page ->
OnboardingPage(onboardingPages[page])
}
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
repeat(onboardingPages.size) { index ->
val selected = pagerState.currentPage == index
Box(
modifier = Modifier
.padding(4.dp)
.size(if (selected) 10.dp else 8.dp)
.clip(CircleShape)
.background(if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant),
)
}
}
}
TrailMate reuses its ViewPager2/TabLayoutMediator onboarding walkthrough here with HorizontalPager and a
hand-rolled dot row instead — pagerState.currentPage (or the fractional pagerState.currentPage for a smoothly interpolated indicator) replaces what
pagerState.currentPageOffsetFractionTabLayoutMediator
wired up automatically in the View world. See
Android Developers — Create a Pager.
A Custom Layout and Intrinsics
Row, Column, Box and even LazyColumn are all themselves built on the same lower-level primitive: the
Layout composable, which measures a list of child Measurable`s against incoming `Constraints and places each
one at an explicit (x, y) position. Writing one directly is reached for only when no combination of
Row/Column/Box/ConstraintLayout expresses the wanted arrangement — for instance, a layout that
overlaps two children by a fixed fraction of the second one’s own measured height, which needs the second
child’s size known before the overlap offset can be computed:
// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/OverlappingLayout.kt
@Composable
fun OverlappingLayout(overlapFraction: Float, modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Layout(content = content, modifier = modifier) { measurables, constraints ->
require(measurables.size == 2) { "OverlappingLayout expects exactly two children" }
val first = measurables[0].measure(constraints)
val second = measurables[1].measure(constraints)
val overlapPx = (second.height * overlapFraction).toInt()
val totalHeight = first.height + second.height - overlapPx
layout(width = constraints.maxWidth, height = totalHeight) {
first.placeRelative(x = 0, y = 0)
second.placeRelative(x = 0, y = first.height - overlapPx)
}
}
}
Each Measurable.measure(constraints) call returns a Placeable that already knows its own measured size, which
is what lets totalHeight be computed from second.height before either child is actually placed; layout(width,
height) { } then reports the custom layout’s own size to its parent and places each Placeable inside the
trailing block. When a custom layout’s parent needs to know its size before it is actually measured — common inside a Row/Column deciding how to distribute space among weighted and non-weighted siblings — the
layout can additionally implement intrinsic measurements (minIntrinsicWidth, maxIntrinsicWidth,
minIntrinsicHeight, maxIntrinsicHeight on the Layout’s `MeasurePolicy), which answer "how wide/tall would
this be given this height/width" without performing a real, side-effecting measurement pass. IntrinsicSize.Min/
Max, used as a Modifier.width(IntrinsicSize.Min), are the common surface most code touches this through — for example, sizing several Row children to the height of the tallest one without a second explicit measure
pass. See Android Developers — Custom Layouts
in Compose (Custom layouts and Intrinsic measurements sections).
List Performance
-
Always pass a
key. As explained above, a missing or unstablekeyis the most common cause of lost item state and unnecessary recomposition in a lazy list. -
Keep item composables stable. A
TrailCardthat takes aTrail(an immutabledata class) and plain lambdas as parameters lets Compose’s stability inference skip recomposing rows whoseTrailvalue did not change — covered in depth on State, Recomposition and Side Effects in Compose. -
Avoid heavyweight work inside item content. Formatting, sorting or filtering belongs in the
TrailListViewModel, computed once per state update, not recomputed inside every row’s composition on every recomposition. -
Prefer
contentTypeonce a list mixes shapes. As shown above, letting unrelated item shapes share acontentType(or omitting it when the list genuinely is homogeneous) forces more expensive recompositions than necessary when items scroll past each other. -
Use
Modifier.animateItem()deliberately, not on everything. Animating every insertion/removal in a very large list adds per-frame cost proportional to how many items are animating at once; a plain filtered list that replaces its whole visible range at once (rather than inserting/removing a few items) rarely needs it. -
Profile with the Layout Inspector and the Compose-aware CPU profiler in Android Studio, and check recomposition counts, before optimizing by guesswork — covered on Debugging, Code Quality and Performance. See also Android Developers — Compose Performance Best Practices and Android Developers — Stability in Compose.
TrailListScreen: Putting It Together
TrailMate’s Compose trail list — the direct counterpart of the legacy TrailListFragment/TrailAdapter from
RecyclerView and Lists — combines everything above: Card for
each row’s surface, Row/Column/Box for its internal layout, LazyColumn with key/contentType/
animateItem() for the scrolling list itself, and PullToRefreshBox around the whole thing:
// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListScreen.kt
@Composable
fun TrailListScreen(
uiState: TrailListUiState,
onTrailClick: (Trail) -> Unit,
onFavoriteClick: (Trail) -> Unit,
onRefresh: () -> Unit,
modifier: Modifier = Modifier,
) {
PullToRefreshBox(
isRefreshing = uiState.isRefreshing,
onRefresh = onRefresh,
modifier = modifier.fillMaxSize(),
) {
if (uiState.trails.isEmpty() && !uiState.isRefreshing) {
EmptyTrailListMessage(modifier = Modifier.align(Alignment.Center))
} else {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxSize(),
) {
items(
items = uiState.trails,
key = { trail -> trail.id },
contentType = { "trail" },
) { trail ->
TrailCard(
trail = trail,
onTrailClick = onTrailClick,
onFavoriteClick = onFavoriteClick,
modifier = Modifier.animateItem(),
)
}
}
}
}
}
// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailCard.kt
@Composable
fun TrailCard(
trail: Trail,
onTrailClick: (Trail) -> Unit,
onFavoriteClick: (Trail) -> Unit,
modifier: Modifier = Modifier,
) {
Card(
onClick = { onTrailClick(trail) },
modifier = modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
) {
Row(
modifier = Modifier.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(modifier = Modifier.size(64.dp)) {
AsyncImage(
model = trail.photoUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(8.dp)),
)
DifficultyBadge(trail.difficulty, modifier = Modifier.align(Alignment.BottomEnd))
}
Column(modifier = Modifier.weight(1f)) {
Text(trail.name, style = MaterialTheme.typography.titleMedium)
Text(trail.region, style = MaterialTheme.typography.bodySmall)
Text("%.1f km".format(trail.lengthKm), style = MaterialTheme.typography.bodySmall)
}
IconToggleButton(checked = trail.isFavorite, onCheckedChange = { onFavoriteClick(trail) }) {
Icon(
imageVector = if (trail.isFavorite) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
contentDescription = if (trail.isFavorite) "Remove from favorites" else "Add to favorites",
)
}
}
}
}
TrailCard accepts trail and plain function-type parameters only, no ViewModel reference, which keeps it
trivially previewable and testable in isolation — see
Instrumented and UI Testing (Espresso, Compose Testing, UI
Automator) for TrailListScreenTest, built against exactly this composable. AsyncImage is Coil’s Compose
image-loading composable, covered in full on
Image Loading, Caching and Transformations with Coil and
Glide; here it plays the same role Glide played inside the legacy TrailViewHolder.bind(). TrailListUiState
and TrailListViewModel — the state this screen renders and how it is produced from TrailRepository — are
covered in full on State, Recomposition and Side Effects in
Compose and ViewModel, Lifecycle-Aware Components,
Coroutines and Flow.