Engineering

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.

MobileByteSensei 8 min read
Day 51 — Text measurement decides your row heights
androidcomposekotlintext

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 case this post is about

One article 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)
        }
    }
}

Some titles wrap to two lines and some do not, so the rows jitter by one line height and the list reads as approximate rather than composed.

Pinning the row height fixes the jitter and introduces truncation that looks like a bug on the long titles. This post follows that one list, because the line count cannot be known in advance — it depends on the string, the width, the font and the user's text scale — so the honest options are to measure it or to constrain it, and the constraint belongs on a different element than the one people reach for.

The symptom: every row a slightly different height

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 a fixed height is the wrong fix

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.

Line-break strategy and hyphenation change where text wraps; baseline padding controls the gap

The mechanism: text height is lineCount × lineHeight plus font padding

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.

The reason you cannot know the count in advance is worth seeing rather than asserting. Line breaking is a greedy scan over break opportunities, and every input to it varies at runtime:

data class Layout(val lines: List<String>, val overflowed: Boolean)

fun breakLines(
    text: String,
    maxWidth: Float,
    charWidth: Float,        // scales with the user's font-size setting
    maxLines: Int,
): Layout {
    val lines = mutableListOf<String>()
    var line = StringBuilder()

    for (word in text.split(" ")) {
        val candidate = if (line.isEmpty()) word else "$line $word"
        // Greedy: take the word if it fits, otherwise break BEFORE it.
        if (candidate.length * charWidth <= maxWidth) {
            line = StringBuilder(candidate)
        } else {
            if (line.isNotEmpty()) lines += line.toString()
            line = StringBuilder(word)
            if (lines.size == maxLines) {
                // The last visible line is truncated, not the overflowing one.
                val last = lines.removeLast()
                val room = (maxWidth / charWidth).toInt() - 1
                lines += last.take(room.coerceAtLeast(1)).trimEnd() + "\u2026"
                return Layout(lines, overflowed = true)
            }
        }
    }
    if (line.isNotEmpty()) lines += line.toString()
    return Layout(lines, overflowed = false)
}

One summary, one width, three font scales — the variable nothing in the layout code controls:

val summary = "A value read in the wrong phase costs you the two phases after it"
listOf(1.0f to 7.2f, 1.3f to 9.4f, 2.0f to 14.4f).forEach { (scale, cw) ->
    val l = breakLines(summary, maxWidth = 328f, charWidth = cw, maxLines = 2)
    println("scale ${scale}x -> ${l.lines.size} lines, overflow=${l.overflowed}")
    l.lines.forEach { println("   |$it") }
}
scale 1.0x -> 2 lines, overflow=false
   |A value read in the wrong phase costs you
   |the two phases after it
scale 1.3x -> 2 lines, overflow=true
   |A value read in the wrong phase
   |costs you the two phases afte…
scale 2.0x -> 2 lines, overflow=true
   |A value read in the
   |wrong phase costs…

Same string, same width, three different outcomes — and only the first is the one anyone looked at. minLines = 2, maxLines = 2 is right here precisely because the count is unknowable: the row is 328dp × two lines at every scale, and what changes is how much of the sentence survives, which is a content decision rather than a layout bug.

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: a screenshot test with realistic content

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: text is the one element whose size is data

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.

One post a week, no digest

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

Follow by RSS