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.

5 min read
androidcomposekotlinlayout

Day 28 — Porting FlexboxLayout: every flex property maps onto somethi

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 symptom

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 the obvious approach fails

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-contentArrangement 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-itemsAlignment 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.

align-selfModifier.align() on the individual child.

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

flex-wrap: wrapFlowRow / 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 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

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.