RizTech Academy logo
RizTech Academy
Layout and ThemingLesson 3 of 730 min

Constraint layouts and flow

Two more layouts. The first, ConstraintLayout, is in almost every XML codebase you will join and was the standard answer for complex screens there; in Compose you will rarely need it, and this lesson shows you why by building the same thing both ways. The second, FlowRow, Mandi Bhav needs today: a row of commodity filters that wraps onto a second line instead of falling off the edge of the screen.

ConstraintLayout, and why Compose rarely needs it

In the XML view system, every nested layout could measure its children more than once, and the cost multiplied with depth. ConstraintLayout let you describe a complex screen as one flat level of views placed relative to each other, and it became the default. Compose measures each child once, as lesson 1 showed, so a flat hierarchy buys almost nothing. The Compose documentation for ConstraintLayout now opens with a warning recommending other layouts, and repeats it under guidelines, barriers and chains.

It still earns its place in a few cases:

  • Relationships between siblings that are not in the same row or column: "this text ends where the widest of those two begins" (a barrier).
  • Swapping whole arrangements, such as a different layout in landscape, by passing a different ConstraintSet to the same content.
  • Migrating an XML screen where keeping the same constraints makes the review easier.

Adding it

ConstraintLayout for Compose is a separate library, androidx.constraintlayout:constraintlayout-compose, with its own version numbers: the Compose BOM does not manage it, so you choose the version yourself. Look it up on Google Maven rather than copying one from a blog post; on 8 November 2026 the latest stable was 1.1.2. In gradle/libs.versions.toml:

[versions]
constraintlayoutCompose = "1.1.2"

[libraries]
androidx-constraintlayout-compose = { group = "androidx.constraintlayout", name = "constraintlayout-compose", version.ref = "constraintlayoutCompose" }

and in app/build.gradle.kts, in dependencies:

implementation(libs.androidx.constraintlayout.compose)

A price header with constraints

The header of a price card with the commodity on the left, the modal price on the right sitting on the commodity's baseline, and the market underneath, never running into the price:

@Composable
fun PriceHeader(price: CommodityPrice, modifier: Modifier = Modifier) {
    ConstraintLayout(modifier = modifier.fillMaxWidth()) {
        val (commodity, market, modal) = createRefs()
        val priceStart = createStartBarrier(modal)

        Text(
            text = price.commodity,
            style = MaterialTheme.typography.titleLarge,
            modifier = Modifier.constrainAs(commodity) {
                top.linkTo(parent.top)
                start.linkTo(parent.start)
            },
        )
        Text(
            text = price.market,
            style = MaterialTheme.typography.bodyMedium,
            modifier = Modifier.constrainAs(market) {
                top.linkTo(commodity.bottom)
                start.linkTo(parent.start)
                end.linkTo(priceStart, margin = 8.dp)
                width = Dimension.fillToConstraints
            },
        )
        Text(
            text = formatRupees(price.modalPrice),
            style = MaterialTheme.typography.headlineSmall,
            modifier = Modifier.constrainAs(modal) {
                baseline.linkTo(commodity.baseline)
                end.linkTo(parent.end)
            },
        )
    }
}
Piece What it does
createRefs() Makes names for the children, destructured into vals
Modifier.constrainAs(ref) { … } Gives a child its name and its constraints
top.linkTo(commodity.bottom) Pins one edge to another child's edge, or to parent
baseline.linkTo(…) Pins text baselines together
createStartBarrier(modal) A line at the start of the furthest-left of the given children
Dimension.fillToConstraints Take exactly the space between the linked edges

Run in a Card on the emulator with Vashi wheat, it shows "Wheat" at the top left, "₹3,000" at the top right on the same baseline, and "Vashi APMC, Navi Mumbai" underneath.

Now the same header with lesson 1's layouts:

@Composable
fun RowPriceHeader(price: CommodityPrice, modifier: Modifier = Modifier) {
    Row(modifier = modifier.fillMaxWidth()) {
        Column(modifier = Modifier.weight(1f).alignByBaseline()) {
            Text(text = price.commodity, style = MaterialTheme.typography.titleLarge)
            Text(text = price.market, style = MaterialTheme.typography.bodyMedium)
        }
        Text(
            text = formatRupees(price.modalPrice),
            style = MaterialTheme.typography.headlineSmall,
            modifier = Modifier.alignByBaseline(),
        )
    }
}

On the emulator the two looked the same: same positions, same baseline. A Column reports the baseline of its first text, so alignByBaseline() on the whole column lines the price up with "Wheat"; weight(1f) keeps the market clear of the price, which is what the barrier did.

The decision: Mandi Bhav does not use ConstraintLayout. The Row version is half the length, uses ideas every Compose developer reads at a glance, and needs no extra library. The library costs a dependency to keep updated on its own schedule, some APK size, and a second layout language for the next developer to learn. If you added it to try the example, remove both lines again; the checkpoint for this module does not have them. Keep the knowledge for the XML screens you will meet in the XML module, and for the rare layout that a Row and a Column cannot express.

FlowRow: a row that wraps

Mandi Bhav now has six commodities. A trader wants to tap "Onion" and see only onions. Seven filter chips ("All" plus six) do not fit across a phone. This is lesson 1's trap again: in a plain Row on the emulator, "All" to "Wheat" fitted, Soybean and Tur dal vanished, and the row grew a tall empty band underneath where the squeezed chips had been measured.

FlowRow places children left to right like a Row and, when the next one does not fit, starts a new line:

FlowRow(
    horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
    FilterChip(
        selected = selectedCommodity == null,
        onClick = { selectedCommodity = null },
        label = { Text("All") },
    )
    for (commodity in commodities) {
        FilterChip(
            selected = selectedCommodity == commodity,
            onClick = {
                selectedCommodity =
                    if (selectedCommodity == commodity) null else commodity
            },
            label = { Text(commodity) },
        )
    }
}

FlowRow and FilterChip are both stable in the Compose version this project uses: neither needs an @OptIn. A FilterChip shows a selected state, and tapping a selected chip again here clears the filter, which is what people expect from a filter.

FlowRow parameter What it does
horizontalArrangement Spacing between items on a line, as in a Row
verticalArrangement Spacing between lines, as in a Column
itemVerticalAlignment How items of different heights line up within a line
maxItemsInEachRow Start a new line after this many, even if more would fit
maxLines Stop after this many lines

FlowColumn does the same top to bottom.

What a FlowRow costs: height. At the largest font size on the emulator, the seven chips took three lines. A horizontally scrolling Row keeps one line, at the cost of hiding chips off the edge, where many users never find them. For seven filters, wrapping is the better trade.

Filtering, out of the composable and under test

Lesson 2 filtered inline. With two filters it is time to take the logic out of the composable, into plain Kotlin that a unit test can call. Create ui/PriceFilter.kt:

package com.riztechacademy.mandibhav.ui

import com.riztechacademy.mandibhav.model.CommodityPrice

/** Each commodity once, in the order it first appears: the filter chips. */
fun commoditiesIn(prices: List<CommodityPrice>): List<String> =
    prices.map { it.commodity }.distinct()

/**
 * The prices to show for a search query and an optional chosen commodity.
 * The query matches commodity or market, ignoring case and surrounding spaces;
 * a null commodity means "all".
 */
fun filterPrices(
    prices: List<CommodityPrice>,
    query: String,
    commodity: String?,
): List<CommodityPrice> {
    val q = query.trim()
    return prices.filter { price ->
        (commodity == null || price.commodity == commodity) &&
            (q.isEmpty() ||
                price.commodity.contains(q, ignoreCase = true) ||
                price.market.contains(q, ignoreCase = true))
    }
}

In PricesScreen, replace the three lines that computed q and visible with:

var query by remember { mutableStateOf("") }
var selectedCommodity by remember { mutableStateOf<String?>(null) }

val commodities = commoditiesIn(prices)
val visible = filterPrices(prices, query, selectedCommodity)

and put the FlowRow between the OutlinedTextField and the empty-results Text. New imports: FlowRow from androidx.compose.foundation.layout and FilterChip from material3.

Then the tests, in app/src/test/java/com/riztechacademy/mandibhav/ui/PriceFilterTest.kt:

package com.riztechacademy.mandibhav.ui

import com.riztechacademy.mandibhav.data.SamplePrices
import org.junit.Assert.assertEquals
import org.junit.Test

class PriceFilterTest {

    private val prices = SamplePrices.all

    @Test
    fun commoditiesAreDistinctInFirstSeenOrder() {
        assertEquals(
            listOf("Onion", "Tomato", "Potato", "Wheat", "Soybean", "Tur dal"),
            commoditiesIn(prices),
        )
    }

    @Test
    fun emptyQueryAndNoCommodityShowsEverything() {
        assertEquals(prices, filterPrices(prices, query = "", commodity = null))
    }

    @Test
    fun commodityChipKeepsOnlyThatCommodity() {
        val onions = filterPrices(prices, query = "", commodity = "Onion")
        assertEquals(3, onions.size)
        assertEquals(setOf("Onion"), onions.map { it.commodity }.toSet())
    }

    @Test
    fun queryMatchesMarketIgnoringCaseAndSpaces() {
        val lasalgaon = filterPrices(prices, query = "  lasalgaon ", commodity = null)
        assertEquals(listOf(SamplePrices.lasalgaonOnion, SamplePrices.lasalgaonSoybean), lasalgaon)
    }

    @Test
    fun queryAndCommodityCombine() {
        assertEquals(
            listOf(SamplePrices.puneTomato),
            filterPrices(prices, query = "pune", commodity = "Tomato"),
        )
        assertEquals(emptyList<Any>(), filterPrices(prices, query = "vashi", commodity = "Onion"))
    }
}
./gradlew testDebugUnitTest

All ten tests pass: these five and module 3's five for formatRupees. What extracting it buys: the rules about case, spaces and combining filters are now checked in milliseconds on every build, without an emulator. When module 5 moves the state into a ViewModel, these functions move with it unchanged.

The chips' selection, like the query, is lost when the phone rotates. Module 5 fixes both.

Check your work

Why ConstraintLayout mattered in XML and matters less in Compose. Nested XML layouts could measure children repeatedly; Compose measures each child once, so nesting is cheap.

Where constraintlayout-compose's version comes from. You choose it; the Compose BOM does not manage it. Check Google Maven for the current stable version.

What a barrier is. A virtual line at the extreme edge of a set of children, which others can be constrained to.

Why Mandi Bhav keeps the Row version. Shorter, familiar, and no extra dependency, for an identical result.

What happened to seven chips in a plain Row. The last two vanished, squeezed to nothing, and the row grew tall around them.

Why the filter logic moved to PriceFilter.kt. So it can be unit-tested without Compose or an emulator, and moved to a ViewModel later unchanged.

Practice 3. On the emulator phone five chips fitted on the first line; with the cap, four do and the other three make the second line. In landscape, where all seven fit on one line, they now break after four as well. Use it when a grid-like look matters more than using the width.

Practice 4. The import fails first: Unresolved reference 'constraintlayout'., then every name from it. Removing a dependency breaks every file that uses it, which is a reason to keep the number of files that do small.

Practice 5. Add this test; it passes, because trim() turns the query into an empty string:

@Test
fun queryOfOnlySpacesShowsEverything() {
    assertEquals(prices, filterPrices(prices, query = "   ", commodity = null))
}

Practice

  1. Add PriceFilter.kt, the chips and the tests. Run the tests, then the app: tap "Onion", type "pune", then tap "Onion" again to clear it.
  2. Replace FlowRow with Row and look at the chips on the emulator. Put FlowRow back.
  3. Add maxItemsInEachRow = 4 to the FlowRow. What changes on a phone, and on a landscape screen?
  4. Break it on purpose: add ConstraintLayout, build PriceHeader, then delete the implementation(libs.androidx.constraintlayout.compose) line and build. Read the first error. Then remove the library and the header properly.
  5. Harder: write a test that a query of only spaces shows every price. Predict whether it passes before you run it, and say which line in filterPrices decides it.

Official documentation

Next: Mandi Bhav's own theme, with its colours and type.

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