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

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".