Yannick
Yannick

Reputation: 5861

Specify minimal lines for Text in Jetpack Compose

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?

Upvotes: 27

Views: 19999

Answers (6)

ubuntudroid
ubuntudroid

Reputation: 3999

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

Upvotes: 3

Atras VidA
Atras VidA

Reputation: 445

create custom Text

it doesn't work in @Preview but the runtime


@Composable
fun MinLineText(
    text: String,
    modifier: Modifier = Modifier,
    color: Color = Color.Unspecified,
    fontSize: TextUnit = TextUnit.Unspecified,
    fontStyle: FontStyle? = null,
    fontWeight: FontWeight? = null,
    fontFamily: FontFamily? = null,
    letterSpacing: TextUnit = TextUnit.Unspecified,
    textDecoration: TextDecoration? = null,
    textAlign: TextAlign? = null,
    lineHeight: TextUnit = TextUnit.Unspecified,
    overflow: TextOverflow = TextOverflow.Clip,
    softWrap: Boolean = true,
    maxLines: Int = Int.MAX_VALUE,
    minLines: Int = 0,
    onTextLayout: (TextLayoutResult) -> Unit = {},
    style: TextStyle = LocalTextStyle.current
) {
    var mText by remember { mutableStateOf(text) }

    Text(
        mText,
        modifier,
        color,
        fontSize,
        fontStyle,
        fontWeight,
        fontFamily,
        letterSpacing,
        textDecoration,
        textAlign,
        lineHeight,
        overflow,
        softWrap,
        maxLines,
        {
            if (it.lineCount < minLines) {
                mText = text + "\n".repeat(minLines - it.lineCount)
            }
            onTextLayout(it)
        },
        style,
    )

}

usage

MinLineText(
    text = "a sample text",
    minLines = 2,
)

Upvotes: 0

Gabriele Mariotti
Gabriele Mariotti

Reputation: 363825

Starting from M2 1.4.0-alpha02 and M3 1.1.0-alpha02 you can use the minLines attribute in the Text:

   Text(
       text = "MinLines = 3",
       modifier = Modifier.fillMaxWidth().background(Yellow),
       minLines = 3
   )

enter image description here

Note that minLines is the minimum height in terms of minimum number of visible lines. It is required that 1 <= minLines <= maxLines.

You can use it with M2 and M3.

Upvotes: 10

dazza5000
dazza5000

Reputation: 7628

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

Upvotes: 1

RareScrap
RareScrap

Reputation: 593

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)

Upvotes: 7

Spatz
Spatz

Reputation: 20118

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

Upvotes: 13

Related Questions