Engineering

Porting FlexboxLayout: every flex property maps onto something, but not onto one thing

There is no FlexboxLayout in Compose because flex's properties split cleanly across Row/Column, Arrangement, Alignment and FlowRow. A direct property-by-property mapping for anyone porting a View layout.

MobileByteSensei 8 min read
Day 28 — Porting FlexboxLayout: every flex property maps onto somethi
androidcomposekotlinlayout

Day 28 of 100, closing the layout pillar. If you're migrating a screen that used Google's FlexboxLayout, or you think in CSS flexbox, this is the translation table — and the one place the translation deliberately doesn't exist.

The case this post is about

One toolbar, ported from CSS, declared as:

// Was: FlexboxLayout, flexDirection=row, justifyContent=space_between, flexWrap=nowrap

Four properties. Three of them map cleanly onto Compose and land in three different parts of the API — the component, an Arrangement, and an Alignment whose parameter name changes with the component. The fourth has no equivalent, by design.

The ported toolbar looked right and its dividers were half-height, because the property that broke it is the one that was never written down: align-items: stretch is CSS's default, so it appears in no stylesheet and a property-by-property port never sees it.

This post follows that one toolbar, because searching for the component name finds nothing and that is the correct outcome.

The symptom: searching for FlexboxLayout in Compose and finding nothing

You search for FlexboxLayout in the Compose docs and find nothing, so you look for the closest single component and land on FlowRow. That's right for one flex configuration and wrong for the others, which produces a port that's subtly off:

// Was: FlexboxLayout, flexDirection=row, justifyContent=space_between, flexWrap=nowrap
FlowRow { Label(); Value() }        // wraps when it shouldn't

FlowRow wraps. The original didn't. The layout is now correct on wide screens and different on narrow ones, which is the hardest kind of regression to notice in review.

Why matching component names is the wrong way to port

The obvious approach is to look for the component whose name matches. Flexbox is one widget with a dozen attributes, so the instinct is that Compose must have one widget with a dozen parameters.

It doesn't, and that's the actual design decision: Compose split flexbox's attributes across the type system. Direction picks the component; distribution and alignment are parameters; wrapping picks a different component; growth is a modifier. Looking for one container means three of the four end up guessed.

Each flexbox property maps to a different part of the Compose API — component, parameter, or modifier

The actual mapping

flex-direction → the component.

row             → Row
column          → Column
row-reverse     → Row(horizontalArrangement = Arrangement.End) + reversed content
column-reverse  → Column(verticalArrangement = Arrangement.Bottom)

There is no reverseLayout parameter on Row. For lazy containers there is — LazyColumn(reverseLayout = true) is how chat screens pin to the bottom — but for a plain Row you reverse the list.

justify-content → Arrangement on the main axis.

flex-start     → Arrangement.Start   / Top
flex-end       → Arrangement.End     / Bottom
center         → Arrangement.Center
space-between  → Arrangement.SpaceBetween
space-around   → Arrangement.SpaceAround
space-evenly   → Arrangement.SpaceEvenly
gap: 8px       → Arrangement.spacedBy(8.dp)

A near-exact match, including the three space-* variants people usually have to look up. spacedBy also composes with the others — Arrangement.spacedBy(8.dp, Alignment.End) is "gap of 8, packed to the end", which CSS needs two properties for.

align-items → Alignment on the cross axis, and note the parameter name changes with the component:

Row(verticalAlignment = Alignment.CenterVertically)
Column(horizontalAlignment = Alignment.CenterHorizontally)

align-items: stretch — the CSS default — is Modifier.fillMaxHeight() on the child in a Row. Compose's default is not stretch, which is the single most common surprise when porting: children that filled the cross axis in flexbox now hug their content.

The mapping is mechanical enough to run, which is a better way to port than reading a table — because the gaps announce themselves:

data class Flex(val direction: String, val justify: String, val align: String, val wrap: String)

fun port(f: Flex): List<String> {
    val component = when (f.direction) {
        "row", "row-reverse"       -> "Row"
        "column", "column-reverse" -> "Column"
        else -> error("unknown flex-direction: ${f.direction}")
    }
    val main = if (component == "Row") "horizontalArrangement" else "verticalArrangement"
    val cross = if (component == "Row") "verticalAlignment" else "horizontalAlignment"
    val arrangement = when (f.justify) {
        "flex-start" -> if (component == "Row") "Arrangement.Start" else "Arrangement.Top"
        "flex-end"   -> if (component == "Row") "Arrangement.End" else "Arrangement.Bottom"
        "center" -> "Arrangement.Center"
        "space-between" -> "Arrangement.SpaceBetween"
        "space-around"  -> "Arrangement.SpaceAround"
        "space-evenly"  -> "Arrangement.SpaceEvenly"
        else -> error("unknown justify-content: ${f.justify}")
    }
    val out = mutableListOf("$component($main = $arrangement,")

    // align-items: stretch is a CHILD modifier, not a parent parameter — and it is
    // the CSS default, so the port has to add something the source never wrote.
    when (f.align) {
        "stretch" -> out += "    // each child: Modifier.${if (component == "Row") "fillMaxHeight" else "fillMaxWidth"}()"
        "center"  -> out += "    $cross = Alignment.Center${if (component == "Row") "Vertically" else "Horizontally"},"
        else      -> out += "    $cross = Alignment.${f.align.replaceFirstChar(Char::uppercase)},"
    }
    if (f.direction.endsWith("-reverse")) out += "    // content reversed at the call site"
    if (f.wrap == "wrap") out[0] = out[0].replace(component, "Flow$component")
    return out + ")"
}

The toolbar that started the port, declared in CSS as row / space-between / stretch / nowrap:

port(Flex("row", "space-between", "stretch", "nowrap")).forEach(::println)
Row(horizontalArrangement = Arrangement.SpaceBetween,
    // each child: Modifier.fillMaxHeight()
)

The comment is the entire value of running it. stretch is CSS's default, so it appears in no stylesheet anywhere — the port that reads the source property-by-property never sees it, and the resulting Row has children hugging their content with the dividers between them half-height. The one line a transliteration cannot produce is the line for the property that was never written down.

align-self → Modifier.align() on the individual child.

flex-grow → Modifier.weight(f). flex-shrink → Modifier.weight(f, fill = false), roughly. flex-basis has no direct equivalent; size the child and let weight distribute the remainder.

flex-wrap: wrap → FlowRow / FlowColumn, which is Day 26 and a genuinely different component rather than a parameter.

The one that doesn't map

order. Flexbox lets you declare children in one order and render them in another. Compose has no equivalent, and won't.

The reason is Day 8: composition identity is positional, so reordering the rendered output without reordering the source would separate a child's slot from its position, which is exactly the class of bug key exists to prevent. If you need a different order, reorder the list:

val ordered = remember(items) { items.sortedBy { it.displayOrder } }
Row { ordered.forEach { Item(it) } }

More honest anyway — order in CSS is well known for breaking tab and screen-reader order, because the visual order and the DOM order stop agreeing. Compose declines to offer the footgun.

Two more that trip people up

aspect-ratio has a direct equivalent, and it's a modifier rather than a container property: Modifier.aspectRatio(16f / 9f). It derives the unconstrained dimension from the constrained one, which is why it pairs with fillMaxWidth() and not with a fixed size().

Nested flex ports one-to-one and is worth resisting anyway. A FlexboxLayout inside a FlexboxLayout becomes a Row inside a Column, which is correct and also the point at which Day 18's single-pass rule is doing real work for you — each level measures its children once, so a deep port doesn't degrade the way a nested RelativeLayout did.

The one nesting case to check is a weight inside a scrolling parent. In flexbox, flex-grow inside an overflowing container silently resolves to the content size; in Compose it throws, because the axis is unbounded. That's Day 21, and the crash is the better behaviour — it tells you at the first run rather than on a narrow device later.

A worked port

<!-- FlexboxLayout: row, wrap, space-between, center, 8dp gaps -->
<com.google.android.flexbox.FlexboxLayout
    app:flexDirection="row"
    app:flexWrap="wrap"
    app:justifyContent="space_between"
    app:alignItems="center" />
FlowRow(
    horizontalArrangement = Arrangement.SpaceBetween,   // justifyContent
    verticalArrangement = Arrangement.spacedBy(8.dp),   // gap BETWEEN LINES
    itemVerticalAlignment = Alignment.CenterVertically, // alignItems
) { items.forEach { Chip(it) } }

Four attributes, four parameters, two of them on a component you only reach for because flexWrap was set. Drop flexWrap and it becomes a Row with verticalAlignment instead — a different component for a one-attribute change, which is exactly the mapping's shape.

How to prove it: port with a screenshot test, not by eye

Port with a screenshot test rather than by eye, since the failure mode is "slightly different on narrow screens":

@Preview(widthDp = 320) @Preview(widthDp = 480) @Preview(fontScale = 2f)
@Composable fun PortedRow() = MyRow(sampleItems)

Three renders against the original screen at the same widths. The stretch default is the one to look for specifically: if children were full-height in the flexbox version and aren't now, you need fillMaxHeight() on them.

Roborazzi or Paparazzi make that a committed test rather than a look-and-see, which is worth the setup on a port of any size — the whole risk in this kind of migration is a hundred small visual deltas that each look plausible in isolation.

What this generalizes to: one-to-one component mapping is usually wrong

The general lesson about porting: a one-to-one component mapping is usually the wrong frame. Frameworks differ in where they put a concept — a widget, a parameter, a modifier, the type system — and searching for the matching widget finds the one case where they happen to agree.

The better question when porting anything is "what decisions does this configuration encode", then finding where the new framework puts each decision. Here that's four places, and knowing that up front is faster than discovering it one attribute at a time.

It also explains why the ported code usually ends up shorter. A flexbox declaration carries every attribute explicitly because the container has no idea what you meant; Row(verticalAlignment = Alignment.CenterVertically) carries one, because choosing Row already said the rest. Encoding decisions in the type rather than in attributes is most of the difference in verbosity between the two systems.

That closes the layout pillar — eleven days from single-pass measurement to porting someone else's layout system. Tomorrow, Day 29 begins adaptive layouts: window size classes, and why screen width in dp is the wrong thing to branch on.


Day 28 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Flexbox alternatives in Compose.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS