Your list rows are different heights because text measurement is honest
Text height depends on content, font and scale, which makes uniform list rows harder than they look. Line-break strategies, hyphenation and baseline-relative padding are the tools that solve it without fixed heights.

Day 51 of 100. Text is the only thing in a layout whose size depends on its content, and that one property causes most of the visual roughness in list-heavy screens.
The symptom
A list where every row is a slightly different height:
LazyColumn {
items(articles, key = { it.id }) { article ->
Column(Modifier.padding(16.dp)) {
Text(article.title, style = MaterialTheme.typography.titleMedium)
Text(article.summary, style = MaterialTheme.typography.bodyMedium, maxLines = 2)
}
}
}
Titles that fit on one line make short rows; two-line titles make tall ones. Summaries shorter than two lines make the row shorter still. Scrolling feels uneven, and the divider rhythm is visibly irregular.
Why the obvious fix fails
The obvious fix is a fixed height:
Column(Modifier.height(120.dp).padding(16.dp)) { … }
Uniform on your device, clipped at 2× font scale, and wasteful in a language where the same content is shorter. Day 46 named this: a fixed height around scalable text is the top cause of accessibility failures.
The second attempt is minLines, which is better and usually applied to the wrong element:
Text(article.title, minLines = 2) // reserves two lines even for short titles
That does produce uniform rows. It also puts a blank line under every short title, which looks like a rendering bug rather than a design.

The actual mechanism
Text height is lineCount × lineHeight plus the font's own padding, and lineCount
depends on the string, the width, the font and the user's scale. You can't know it in
advance, so the honest approaches are to measure or to constrain.
Constrain, when the design wants uniformity. minLines and maxLines together pin a
range:
Text(
article.summary,
style = MaterialTheme.typography.bodyMedium,
minLines = 2,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Applied to the summary rather than the title, this gives uniform rows without the empty line looking wrong — a truncated two-line summary reads as intentional in a way a padded one-line title does not.
Measure, when you need the number. onTextLayout from yesterday gives you
lineCount, hasVisualOverflow and per-line bounds after layout, which is what a
"read more" affordance or a custom highlight needs.
Line breaking is configurable
LineBreak chooses the algorithm, and the defaults are deliberately fast rather than
best:
Text(
text = body,
style = MaterialTheme.typography.bodyLarge.copy(
lineBreak = LineBreak.Paragraph, // best quality — for body text
),
)
Three strategies:
LineBreak.Simple— greedy, fastest. The default for most styles, and correct for a single-line label.LineBreak.Heading— tuned for short, prominent text; avoids awkward single-word last lines.LineBreak.Paragraph— optimises the whole paragraph, producing more even line lengths. Noticeably better for running text, and more expensive.
LineBreak.Paragraph on a body-text style is one of the cheapest visual improvements
available; using it inside a LazyColumn item that measures on every scroll frame is not.
Hyphenation is separate and off by default:
style = TextStyle(hyphens = Hyphens.Auto)
Worth enabling for narrow columns and for languages with long compounds — German, Finnish — where the alternative is a very ragged right edge.
The first-baseline problem
Padding above text is measured from the text box, not the baseline, so the visual gap
depends on the font's ascent. Two different type styles with the same padding(16.dp)
look differently spaced.
For a design system where spacing is specified from the baseline — which is how most type specs are written — there's a modifier:
Text(
title,
modifier = Modifier.paddingFromBaseline(top = 32.dp, bottom = 16.dp),
style = MaterialTheme.typography.titleLarge,
)
Now the distance from the previous element to the baseline is 32dp regardless of font size, which is what a designer means by "32 above the title". This is the single most useful text modifier that most codebases never use.
Related: includeFontPadding was on by default in the View system and added asymmetric
space above and below. Compose turned it off, which is why text sometimes sits tighter
than the same string in a TextView — and why paddingFromBaseline is the right tool
rather than compensating with extra padding.
Alignment, and the one that isn't
Text(body, textAlign = TextAlign.Justify)
Justification is available and worth using sparingly — without good hyphenation it
produces rivers of whitespace in narrow columns. TextAlign.Start/End are
direction-aware; Left/Right are not, and are almost always a bug in an app that ships
in more than one language.
TextAlign.Center has its own trap in a list: centred text of varying length has no
common left edge, so the eye has nothing to track down the column. It reads well for a
single headline and poorly for anything repeated.
How to prove it
The uniformity question is a screenshot test, and it only works with realistic content:
@Test fun rowsAreUniform() {
captureRoboImage("article_list.png") {
AppTheme { ArticleList(articlesWithVaryingTitleLengths) }
}
}
Test data with one-word titles and forty-word titles finds the problem that evenly-sized fake data hides. This is worth saying plainly: sample data that's all the same length is why these bugs reach production.
The same applies to the fixture strings themselves: filler text made of uniform short words breaks differently from real prose, which has long words, punctuation and the occasional URL. Sampling a few hundred real titles out of the production database into a fixture file is a half-hour of work that makes every subsequent layout test meaningful.
For font scale, the preview annotation again:
@Preview(fontScale = 2f, widthDp = 320)
@Composable fun ArticleRowLarge() = AppTheme { ArticleRow(longTitleSample) }
Anything that clips has a fixed dimension. Anything that becomes ten lines tall needs
maxLines.
Pseudolocales find it faster than translation
Android ships two pseudolocales that need no translator:
adb shell setprop persist.sys.locale en-XA # accented, ~30% longer
adb shell setprop persist.sys.locale ar-XB # right-to-left, mirrored
en-XA renders every string longer and accented, which surfaces truncation and
overflow immediately. ar-XB mirrors the layout, which catches every TextAlign.Left
and padding(start = …) that should have been direction-aware.
Turning both on for a single pass through the app is around ten minutes and finds more layout bugs than any amount of staring at the English build — precisely because the English build is the one case everyone has already looked at.
What this generalizes to
The lesson is text is the one element whose size is data. Everything else in a layout can be sized by rule; text has to be measured, and any design that assumes a height is making a claim about content, font and locale that will eventually be false.
The techniques all follow from accepting that: constrain the line count if uniformity matters, measure if you need the number, and specify spacing from the baseline so the rhythm survives a font change. Fighting it with fixed heights works until the first translation or the first accessibility setting.
Print typography solved this centuries ago with the same two tools — a measure (the column width) and a leading (the line height) — and left the line count to fall where it falls. Digital layout keeps rediscovering that the count is an output, not an input.
Tomorrow, Day 52: text field state in depth — the piece Day 38 introduced, and the undo, selection and IME behaviour underneath it.
Day 51 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Paragraph style.