Specify minimal lines for Text in Jetpack Compose

Viewed 5457

For various reasons a Text should always have at least the height equal to x lines of text, no matter if it has less than x lines of text. The Text and BasicText Composables only have a maxLines parameter but no minLines

I have tried the following (x = 3):

Text(
    modifier = Modifier.sizeIn(minHeight = with(LocalDensity.current) {
       (42*3).sp.toDp()
    }),
    color = MaterialTheme.colors.onPrimary,
    text = "Sample", textAlign = TextAlign.Center,
    style = MaterialTheme.typography.h2 /* fontSize = 42 */,
    lineHeight = 42.sp
)

The resulting height is less than if the text would contain 3 lines

Back in View World Android, we could simply use minLines=3, how can we achieve this in Jetpack Compose?

4 Answers

Your code is almost correct, just set lineHeight to fontSize*4/3:

var lineHeight = MaterialTheme.typography.h2.fontSize*4/3

Text(
    modifier = Modifier.sizeIn(minHeight = with(LocalDensity.current) {
       (lineHeight*3).toDp()
    }),
    color = MaterialTheme.colors.onPrimary,
    text = "Sample", textAlign = TextAlign.Center,
    style = MaterialTheme.typography.h2,
    lineHeight = lineHeight
)

But you can do something similar without calculations using onTextLayout callback:

fun main() = Window {
    var text by remember { mutableStateOf("Hello, World!") }
    var lines by remember { mutableStateOf(0) }

    MaterialTheme {
        Button(onClick = {
            text += "\nnew line"
        }) {
            Column {
                Text(text,
                    maxLines = 5,
                    style = MaterialTheme.typography.h2,
                    onTextLayout = { res -> lines = res.lineCount })
                for (i in lines..2) {
                    Text(" ", style = MaterialTheme.typography.h2)
                }
            }
        }
    }
}

While we are waiting for Google implements this feature you can use this workaround:

@Preview
@Composable
fun MinLinesPreview() {
    lateinit var textLayoutResult: TextLayoutResult

    val text = "Title\ntitle\nTITLE\nTitle"
//    val text = "title\ntitle\ntitle\ntitle"
//    val text = "title\ntitle"
//    val text = "title"

    Text(
        modifier = Modifier.fillMaxWidth(),
        text = text.addEmptyLines(3), // ensures string has at least N lines,
        textAlign = TextAlign.Center,
        maxLines = 4,
    )
}

fun String.addEmptyLines(lines: Int) = this + "\n".repeat(lines)

Now your Text has the same height regardless string content:

Text height with 1 line Text height with 2 lines Text height with 4 lines Text height with 4 lines with capslock

This solution is much more easier than calculate Text's bottom offset based on line height in onTextLayout (spoiler: start, center and last line have different height)

If one additional recomposition of the Text is fine for you, you can also make use of the onTextLayout callback of Text as a workaround until there is official support for minimum lines from Google:

val minLineCount = 4
var text by remember { mutableStateOf(description) }
Text(
    text = text,
    maxLines = minLineCount, // optional, if you want the Text to always be exactly 4 lines long
    overflow = TextOverflow.Ellipsis, // optional, if you want ellipsizing
    textAlign = TextAlign.Center,
    onTextLayout = { textLayoutResult ->
        // the following causes a recomposition if there isn't enough text to occupy the minimum number of lines!
        if ((textLayoutResult.lineCount) < minLineCount) {
            // don't forget the space after the line break, otherwise empty lines won't get full height!
            text = description + "\n ".repeat(exactLineCount - textLayoutResult.lineCount) 
        }
    },
    modifier = Modifier.fillMaxWidth()
)

This will also properly work with ellipsizing and any kind of font padding, line height style etc. settings your heart desires.

A "fake" 4-line Text (with, say, 2 empty lines at the end) will have the same height like a "real" 4 line Text with 4 fully occupied lines of text. This oftentimes can be super important when e.g. laying out multiple wrap_content-height cards horizontally next to each other and the Text (in combination with maxLines) should determine the height of the cards, while all cards should have the same height (and it should work in regular and tall languages, like Burmese).

Please note, that this will not work in Android Studio's preview. My guess is, that Studio doesn't allow recompositions in the preview for performance reasons.

Below is a solution that I came up with that will set the height to a specific number of lines (you could adapt the modifier to make it minLines) It is inspired by code found from the compose SDK

// Inspiration: https://github.com/androidx/androidx/blob/6075c715aea671a616890dd7f0fc9a50d96e75b9/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/MaxLinesHeightModifier.kt#L38
fun Modifier.minLinesHeight(
minLines: Int,
textStyle: TextStyle
) = composed {
val density = LocalDensity.current
val layoutDirection = LocalLayoutDirection.current

val resolvedStyle = remember(textStyle, layoutDirection) {
    resolveDefaults(textStyle, layoutDirection)
}
val resourceLoader = LocalFontLoader.current

val heightOfTextLines = remember(
    density,
    textStyle,
    layoutDirection
) {
    val lines = (EmptyTextReplacement + "\n").repeat(minLines - 1)

    computeSizeForDefaultText(
        style = resolvedStyle,
        density = density,
        text = lines,
        maxLines = minLines,
        resourceLoader
    ).height
}

val heightInDp: Dp = with(density) { heightOfTextLines.toDp() }
val heightToSet = heightInDp + OutlinedTextBoxDecoration

Modifier.height(heightToSet)
}

// Source: https://github.com/androidx/androidx/blob/6075c715aea671a616890dd7f0fc9a50d96e75b9/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/TextFieldDelegate.kt#L61
fun computeSizeForDefaultText(
style: TextStyle,
density: Density,
text: String = EmptyTextReplacement,
maxLines: Int = 1,
resourceLoader: Font.ResourceLoader
): IntSize {
val paragraph = Paragraph(
    paragraphIntrinsics = ParagraphIntrinsics(
        text = text,
        style = style,
        density = density,
        resourceLoader = resourceLoader
    ),

    maxLines = maxLines,
    ellipsis = false,
    width = Float.POSITIVE_INFINITY
)

return IntSize(paragraph.minIntrinsicWidth.ceilToIntPx(), paragraph.height.ceilToIntPx())
}

// Source: https://github.com/androidx/androidx/blob/6075c715aea671a616890dd7f0fc9a50d96e75b9/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/TextFieldDelegate.kt#L47
internal const val DefaultWidthCharCount = 10
internal val EmptyTextReplacement = "H".repeat(DefaultWidthCharCount)

// Needed because paragraph only calculates the height to display the text and not the entire height
// to display the decoration of the TextField Widget
internal val OutlinedTextBoxDecoration = 40.dp

// Source: https://github.com/androidx/androidx/blob/6075c715aea671a616890dd7f0fc9a50d96e75b9/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/TextDelegate.kt#L296
internal fun Float.ceilToIntPx(): Int = ceil(this).roundToInt()

Additional discussion on this implementation and other options can be found here:

https://kotlinlang.slack.com/archives/CJLTWPH7S/p1621789835172600

Related