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:

  • Row places its children left to right (or right to left, mirrored automatically in a right-to-left locale) along a single horizontal line.

  • Column places its children top to bottom along a single vertical line.

  • Box stacks 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

Modifier.size(64.dp) / .width() / .height()

Fixes an exact size (or one dimension), overriding what the child would otherwise measure itself as.

Modifier.fillMaxWidth() / .fillMaxHeight() / .fillMaxSize()

Grows to fill the available space in one or both dimensions, optionally scaled with a fraction argument (fillMaxWidth(0.5f)).

Modifier.padding(16.dp) / .padding(horizontal = , vertical = )

Adds inset space around the content, shrinking what is available to it — see the ordering discussion above.

Modifier.background(color, shape)

Paints a solid color (or Brush) behind the content, optionally clipped to a Shape.

Modifier.clip(shape)

Clips the element’s drawing (and touch target) to a Shape, most often RoundedCornerShape(12.dp) or CircleShape.

Modifier.clickable { } / .combinedClickable(onClick =, onLongClick = )

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 pointerInput tap detector for a plain click, since it also wires up semantics and keyboard/switch-access activation for free.

Modifier.weight(1f)

Shares remaining main-axis space inside a Row/Column, as above.

Modifier.align(Alignment.BottomEnd)

Overrides a single child’s alignment inside a Box, or its cross-axis alignment inside a Row/Column.

Modifier.testTag("trailCard")

Attaches a stable identifier for Compose UI tests — see Instrumented and UI Testing (Espresso, Compose Testing, UI Automator).

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:

  • key gives each item a stable identity across recompositions, independent of its position — trail.id above, never the loop index. Without a key, 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 as remember blocks and animations that a key-based identity would have preserved by recognizing the item as merely having moved.

  • contentType tells 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 same contentType reuse 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 in TrailCard above, animates an item when it is inserted, removed or moved — the Compose analog of RecyclerView’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) and fadeOutSpec (the item disappearing); each defaults to a spring and can be set to null to 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
pagerState.currentPageOffsetFraction
for a smoothly interpolated indicator) replaces what TabLayoutMediator 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 unstable key is the most common cause of lost item state and unnecessary recomposition in a lazy list.

  • Keep item composables stable. A TrailCard that takes a Trail (an immutable data class) and plain lambdas as parameters lets Compose’s stability inference skip recomposing rows whose Trail value 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 contentType once a list mixes shapes. As shown above, letting unrelated item shapes share a contentType (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.