android:autoSizeTextType in Jetpack Compose

Viewed 13763

Is there a way to adjust the text to always resize depending a fixed height ?

I have a column that has a fixed height and in which the text inside should always fit

 Column(modifier = Modifier.height(150.dp).padding(8.dp)) {
   Text("My really long long long long long text that needs to be resized to the height of this Column")
}
12 Answers

I use the following to adjust the font size with respect to the available width:

val textStyleBody1 = MaterialTheme.typography.body1
var textStyle by remember { mutableStateOf(textStyleBody1) }
var readyToDraw by remember { mutableStateOf(false) }
Text(
    text = "long text goes here",
    style = textStyle,
    maxLines = 1,
    softWrap = false,
    modifier = modifier.drawWithContent {
        if (readyToDraw) drawContent()
    },
    onTextLayout = { textLayoutResult ->
        if (textLayoutResult.didOverflowWidth) {
            textStyle = textStyle.copy(fontSize = textStyle.fontSize * 0.9)
        } else {
            readyToDraw = true
        }
    }
)

To adjust the font size based on height, play around with the Text composable's attributes and use didOverflowHeight instead of didOverflowWidth:

val textStyleBody1 = MaterialTheme.typography.body1
var textStyle by remember { mutableStateOf(textStyleBody1) }
var readyToDraw by remember { mutableStateOf(false) }
Text(
    text = "long text goes here",
    style = textStyle,
    overflow = TextOverflow.Clip,
    modifier = modifier.drawWithContent {
        if (readyToDraw) drawContent()
    },
    onTextLayout = { textLayoutResult ->
        if (textLayoutResult.didOverflowHeight) {
            textStyle = textStyle.copy(fontSize = textStyle.fontSize * 0.9)
        } else {
            readyToDraw = true
        }
    }
)

In case you need to synchronize the font size across multiple items in a list, save the text style outside of the composable function:

private val textStyle = mutableStateOf(MaterialTheme.typography.body1)

@Composable
fun YourComposable() {
    Text(...)
}

This is certainly not perfect, as it might take some frames until the size fits and the text draws finally.

I built on top of Brian's answer to support other properties of Text which are also hoisted and can be used by the caller.

@Composable
fun AutoResizeText(
    text: String,
    fontSizeRange: FontSizeRange,
    modifier: Modifier = Modifier,
    color: Color = Color.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,
    style: TextStyle = LocalTextStyle.current,
) {
    var fontSizeValue by remember { mutableStateOf(fontSizeRange.max.value) }
    var readyToDraw by remember { mutableStateOf(false) }

    Text(
        text = text,
        color = color,
        maxLines = maxLines,
        fontStyle = fontStyle,
        fontWeight = fontWeight,
        fontFamily = fontFamily,
        letterSpacing = letterSpacing,
        textDecoration = textDecoration,
        textAlign = textAlign,
        lineHeight = lineHeight,
        overflow = overflow,
        softWrap = softWrap,
        style = style,
        fontSize = fontSizeValue.sp,
        onTextLayout = {
            Timber.d("onTextLayout")
            if (it.didOverflowHeight && !readyToDraw) {
                Timber.d("Did Overflow height, calculate next font size value")
                val nextFontSizeValue = fontSizeValue - fontSizeRange.step.value
                if (nextFontSizeValue <= fontSizeRange.min.value) {
                    // Reached minimum, set minimum font size and it's readToDraw
                    fontSizeValue = fontSizeRange.min.value
                    readyToDraw = true
                } else {
                    // Text doesn't fit yet and haven't reached minimum text range, keep decreasing
                    fontSizeValue = nextFontSizeValue
                }
            } else {
                // Text fits before reaching the minimum, it's readyToDraw
                readyToDraw = true
            }
        },
        modifier = modifier.drawWithContent { if (readyToDraw) drawContent() }
    )
}

data class FontSizeRange(
    val min: TextUnit,
    val max: TextUnit,
    val step: TextUnit = DEFAULT_TEXT_STEP,
) {
    init {
        require(min < max) { "min should be less than max, $this" }
        require(step.value > 0) { "step should be greater than 0, $this" }
    }

    companion object {
        private val DEFAULT_TEXT_STEP = 1.sp
    }
}

And the usage would look like:

AutoResizeText(
    text = "Your Text",
    maxLines = 3,
    modifier = Modifier.fillMaxWidth(),
    fontSizeRange = FontSizeRange(
        min = 10.sp,
        max = 22.sp,
    ),
    overflow = TextOverflow.Ellipsis,
    style = MaterialTheme.typography.body1,
)

This way I was able to set different maxLines and even have Ellipsis as overflow as the text was just too big to fit into the set lines even with the smallest size we want.

This is a composable based on @Brian and @zxon comments to autosize the Text based on the available width.

@Composable
fun AutoSizeText(
    text: String,
    textStyle: TextStyle,
    modifier: Modifier = Modifier
) {
    var scaledTextStyle by remember { mutableStateOf(textStyle) }
    var readyToDraw by remember { mutableStateOf(false) }

    Text(
            text,
            modifier.drawWithContent {
                if (readyToDraw) {
                    drawContent()
                }
            },
            style = scaledTextStyle,
            softWrap = false,
            onTextLayout = { textLayoutResult ->
                if (textLayoutResult.didOverflowWidth) {
                    scaledTextStyle =
                            scaledTextStyle.copy(fontSize = scaledTextStyle.fontSize * 0.9)
                } else {
                    readyToDraw = true
                }
            }
    )
}

The preview doesn't work correctly with this (at least with beta09), you can add this code to use a placeholder for the preview:

    if (LocalInspectionMode.current) {
        Text(
                text,
                modifier,
                style = textStyle
        )
        return
    } 

(Works with preview) Here's another solution using BoxWithConstraints to get the available width and compare it to the width that's needed to lay out the Text in one line, using ParagraphIntrinsics:

@Composable
private fun AutosizeText(
    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,
    onTextLayout: (TextLayoutResult) -> Unit = {},
    style: TextStyle = LocalTextStyle.current
) {
    BoxWithConstraints {
        var shrunkFontSize = fontSize
        val calculateIntrinsics = @Composable {
            ParagraphIntrinsics(
                text, TextStyle(
                    color = color,
                    fontSize = shrunkFontSize,
                    fontWeight = fontWeight,
                    textAlign = textAlign,
                    lineHeight = lineHeight,
                    fontFamily = fontFamily,
                    textDecoration = textDecoration,
                    fontStyle = fontStyle,
                    letterSpacing = letterSpacing
                ),
                density = LocalDensity.current,
                resourceLoader = LocalFontLoader.current
            )
        }

        var intrinsics = calculateIntrinsics()
        with(LocalDensity.current) {
            while (intrinsics.maxIntrinsicWidth > maxWidth.toPx()) {
                shrunkFontSize *= 0.9
                intrinsics = calculateIntrinsics()
            }
        }
        Text(
            text = text,
            modifier = modifier,
            color = color,
            fontSize = shrunkFontSize,
            fontStyle = fontStyle,
            fontWeight = fontWeight,
            fontFamily = fontFamily,
            letterSpacing = letterSpacing,
            textDecoration = textDecoration,
            textAlign = textAlign,
            lineHeight = lineHeight,
            onTextLayout = onTextLayout,
            style = style
        )
    }
}

I did something like this

@Composable
fun AutosizeText() {

    var multiplier by remember { mutableStateOf(1f) }

    Text(
        "Some long-ish text",
        maxLines = 1, // modify to fit your need
        overflow = TextOverflow.Visible,
        style = LocalTextStyle.current.copy(
            fontSize = LocalTextStyle.current.fontSize * multiplier
        ),
        onTextLayout = {
            if (it.hasVisualOverflow) {
                multiplier *= 0.99f // you can tune this constant 
            }
        }
    )
}

you can visually see the text shrinking till it fits

I'd like to add that, if you do not want to see the middle states from the answer of @Brian, you can try this.

        modifier = Modifier
            .drawWithContent {
                if (calculationFinish) { // replace by your logic 
                    drawContent()
                }
            },

try BoxWithConstraints, and learn the SubcomposeLayout concept

BoxWithConstraints(
    modifier = Modifier
        .fillMaxWidth()
        .weight(5f)
) {
    val size = min(maxWidth * 1.7f, maxHeight)
    val fontSize = size * 0.8f
    Text(
        text = first,
        color = color,
        fontSize = LocalDensity.current.run { fontSize.toSp() },
        modifier = Modifier.fillMaxSize(),
        textAlign = TextAlign.Center,
    )
}

Update: This may have stopped working after the 1.0.1 release....

Another way to do this inspired by @nieto's answer is to resize without recomposing by just manually measuring using the paragraph block given the inbound constraints. Also previews correctly as a bonus



@Composable
fun AutoSizeText(
    text: String,
    style: TextStyle,
    modifier: Modifier = Modifier,
    minTextSize: TextUnit = TextUnit.Unspecified,
    maxLines: Int = Int.MAX_VALUE,
) {
    BoxWithConstraints(modifier) {
        var combinedTextStyle = LocalTextStyle.current + style

        while (shouldShrink(text, combinedTextStyle, minTextSize, maxLines)) {
            combinedTextStyle = combinedTextStyle.copy(fontSize = combinedTextStyle.fontSize * .9f)
        }

        Text(
            text = text,
            style = style + TextStyle(fontSize = combinedTextStyle.fontSize),
            maxLines = maxLines,
        )
    }
}

@Composable
private fun BoxWithConstraintsScope.shouldShrink(
    text: String,
    combinedTextStyle: TextStyle,
    minimumTextSize: TextUnit,
    maxLines: Int
): Boolean = if (minimumTextSize == TextUnit.Unspecified || combinedTextStyle.fontSize > minimumTextSize) {
    false
} else {
    val paragraph = Paragraph(
        text = text,
        style = combinedTextStyle,
        width = maxWidth.value,
        maxLines = maxLines,
        density = LocalDensity.current,
        resourceLoader = LocalFontLoader.current,
    )
    paragraph.height > maxHeight.value
}

This is based on Robert's solution but it works with maxLines and height constraints.

AutoSize Preview

@Preview
@Composable
fun AutoSizePreview1() {
    Box(Modifier.size(200.dp, 300.dp)) {
        AutoSizeText(text = "This is a bunch of text that will fill the box", maxFontSize = 250.sp, maxLines = 2)
    }
}

@Preview
@Composable
fun AutoSizePreview2() {
    Box(Modifier.size(200.dp, 300.dp)) {
        AutoSizeText(text = "This is a bunch of text that will fill the box", maxFontSize = 25.sp)
    }
}

@Preview
@Composable
fun AutoSizePreview3() {
    Box(Modifier.size(200.dp, 300.dp)) {
        AutoSizeText(text = "This is a bunch of text that will fill the box")
    }
}

@Composable
fun AutoSizeText(
    text: String,
    modifier: Modifier = Modifier,
    acceptableError: Dp = 5.dp,
    maxFontSize: TextUnit = TextUnit.Unspecified,
    color: Color = Color.Unspecified,
    fontStyle: FontStyle? = null,
    fontWeight: FontWeight? = null,
    fontFamily: FontFamily? = null,
    letterSpacing: TextUnit = TextUnit.Unspecified,
    textDecoration: TextDecoration? = null,
    textAlign: TextAlign? = null,
    contentAlignment: Alignment? = null,
    lineHeight: TextUnit = TextUnit.Unspecified,
    maxLines: Int = Int.MAX_VALUE,
    onTextLayout: (TextLayoutResult) -> Unit = {},
    style: TextStyle = LocalTextStyle.current
) {
    val alignment: Alignment = contentAlignment ?: when (textAlign) {
        TextAlign.Left -> Alignment.TopStart
        TextAlign.Right -> Alignment.TopEnd
        TextAlign.Center -> Alignment.Center
        TextAlign.Justify -> Alignment.TopCenter
        TextAlign.Start -> Alignment.TopStart
        TextAlign.End -> Alignment.TopEnd
        else -> Alignment.TopStart
    }
    BoxWithConstraints(modifier = modifier, contentAlignment = alignment) {
        var shrunkFontSize = if (maxFontSize.isSpecified) maxFontSize else 100.sp

        val calculateIntrinsics = @Composable {
            val mergedStyle = style.merge(
                TextStyle(
                    color = color,
                    fontSize = shrunkFontSize,
                    fontWeight = fontWeight,
                    textAlign = textAlign,
                    lineHeight = lineHeight,
                    fontFamily = fontFamily,
                    textDecoration = textDecoration,
                    fontStyle = fontStyle,
                    letterSpacing = letterSpacing
                )
            )
            Paragraph(
                text = text,
                style = mergedStyle,
                constraints = Constraints(maxWidth = ceil(LocalDensity.current.run { maxWidth.toPx() }).toInt()),
                density = LocalDensity.current,
                fontFamilyResolver = LocalFontFamilyResolver.current,
                spanStyles = listOf(),
                placeholders = listOf(),
                maxLines = maxLines,
                ellipsis = false
            )
        }

        var intrinsics = calculateIntrinsics()

        val targetWidth = maxWidth - acceptableError / 2f

        check(targetWidth.isFinite || maxFontSize.isSpecified) { "maxFontSize must be specified if the target with isn't finite!" }

        with(LocalDensity.current) {
            // this loop will attempt to quickly find the correct size font by scaling it by the error
            // it only runs if the max font size isn't specified or the font must be smaller
            // minIntrinsicWidth is "The width for text if all soft wrap opportunities were taken."
            if (maxFontSize.isUnspecified || targetWidth < intrinsics.minIntrinsicWidth.toDp())
                while ((targetWidth - intrinsics.minIntrinsicWidth.toDp()).toPx().absoluteValue.toDp() > acceptableError / 2f) {
                    shrunkFontSize *= targetWidth.toPx() / intrinsics.minIntrinsicWidth
                    intrinsics = calculateIntrinsics()
                }
            // checks if the text fits in the bounds and scales it by 90% until it does
            while (intrinsics.didExceedMaxLines || maxHeight < intrinsics.height.toDp() || maxWidth < intrinsics.minIntrinsicWidth.toDp()) {
                shrunkFontSize *= 0.9f
                intrinsics = calculateIntrinsics()
            }
        }

        if (maxFontSize.isSpecified && shrunkFontSize > maxFontSize)
            shrunkFontSize = maxFontSize

        Text(
            text = text,
            color = color,
            fontSize = shrunkFontSize,
            fontStyle = fontStyle,
            fontWeight = fontWeight,
            fontFamily = fontFamily,
            letterSpacing = letterSpacing,
            textDecoration = textDecoration,
            textAlign = textAlign,
            lineHeight = lineHeight,
            onTextLayout = onTextLayout,
            maxLines = maxLines,
            style = style
        )
    }
}

I found that in @EmbMicro answer maxlines sometimes gets ignored. I fixed that issue and also replaced the deprecated call to Paragraph with constraints instead of with

@Composable
fun AutoSizeText(
    text: String,
    modifier: Modifier = Modifier,
    acceptableError: Dp = 5.dp,
    maxFontSize: TextUnit = TextUnit.Unspecified,
    color: Color = Color.Unspecified,
    fontStyle: FontStyle? = null,
    fontWeight: FontWeight? = null,
    fontFamily: FontFamily? = null,
    letterSpacing: TextUnit = TextUnit.Unspecified,
    textDecoration: TextDecoration? = null,
    textAlign: TextAlign? = null,
    lineHeight: TextUnit = TextUnit.Unspecified,
    maxLines: Int = Int.MAX_VALUE,
    onTextLayout: (TextLayoutResult) -> Unit = {},
    style: TextStyle = LocalTextStyle.current
) {
    BoxWithConstraints(modifier = modifier) {
        var shrunkFontSize = if (maxFontSize.isSpecified) maxFontSize else 100.sp

        val calculateIntrinsics = @Composable {
            val mergedStyle = style.merge(
                TextStyle(
                    color = color,
                    fontSize = shrunkFontSize,
                    fontWeight = fontWeight,
                    textAlign = textAlign,
                    lineHeight = lineHeight,
                    fontFamily = fontFamily,
                    textDecoration = textDecoration,
                    fontStyle = fontStyle,
                    letterSpacing = letterSpacing
                )
            )
            Paragraph(
                text = text,
                style = mergedStyle,
                spanStyles = listOf(),
                placeholders = listOf(),
                maxLines = maxLines,
                ellipsis = false,
                constraints = Constraints(maxWidth = ceil(LocalDensity.current.run { maxWidth.toPx() }).toInt()) ,
                density = LocalDensity.current,
                fontFamilyResolver = LocalFontFamilyResolver.current
            )
        }

        var intrinsics = calculateIntrinsics()

        val targetWidth = maxWidth - acceptableError / 2f

        with(LocalDensity.current) {
            if (maxFontSize.isUnspecified || targetWidth < intrinsics.minIntrinsicWidth.toDp() || intrinsics.didExceedMaxLines) {
                while ((targetWidth - intrinsics.minIntrinsicWidth.toDp()).toPx().absoluteValue.toDp() > acceptableError / 2f) {
                    shrunkFontSize *= targetWidth.toPx() / intrinsics.minIntrinsicWidth
                    intrinsics = calculateIntrinsics()
                }
                while (intrinsics.didExceedMaxLines || maxHeight < intrinsics.height.toDp()) {
                    shrunkFontSize *= 0.9f
                    intrinsics = calculateIntrinsics()
                }
            }
        }

        if (maxFontSize.isSpecified && shrunkFontSize > maxFontSize)
            shrunkFontSize = maxFontSize

        Text(
            text = text,
            color = color,
            fontSize = shrunkFontSize,
            fontStyle = fontStyle,
            fontWeight = fontWeight,
            fontFamily = fontFamily,
            letterSpacing = letterSpacing,
            textDecoration = textDecoration,
            textAlign = textAlign,
            lineHeight = lineHeight,
            onTextLayout = onTextLayout,
            maxLines = maxLines,
            style = style
        )
    }
}

Tweaked a little solution from Thad C

Compose version: 1.1.0-beta02

Preview works

No blinking when text changes, text changes are handled quickly (though would be even better if text size calculation would be launched as coroutine on another thread)

@Composable
fun AutoSizeText(
    text: AnnotatedString,
    minTextSizeSp: Float,
    maxTextSizeSp: Float,
    modifier: Modifier = Modifier,
    color: Color = Color.Unspecified,
    fontStyle: FontStyle? = null,
    fontWeight: FontWeight? = null,
    fontFamily: FontFamily? = null,
    textAlign: TextAlign? = null,
    style: TextStyle = LocalTextStyle.current,
    contentAlignment: Alignment = Alignment.TopStart,
) {
    check(minTextSizeSp > 0) { "Min text size should above zero" }
    check(minTextSizeSp < maxTextSizeSp) { "Min text size should be smaller then max text size" }
    BoxWithConstraints(modifier, contentAlignment = contentAlignment) {
        val textString = text.toString()
        val currentStyle = style.copy(
            color = color,
            fontStyle = fontStyle ?: style.fontStyle,
            fontSize = maxTextSizeSp.sp,
            fontWeight = fontWeight ?: style.fontWeight,
            fontFamily = fontFamily ?: style.fontFamily,
            textAlign = textAlign,
        )
        val fontChecker = createFontChecker(currentStyle, textString)
        val fontSize = remember(textString) {
            fontChecker.findMaxFittingTextSize(minTextSizeSp, maxTextSizeSp)
        }

        Text(
            text = text,
            style = currentStyle + TextStyle(fontSize = fontSize),
            color = color,
            textAlign = textAlign
        )
    }
}

@Composable
private fun BoxWithConstraintsScope.createFontChecker(currentStyle: TextStyle, text: String): FontChecker {
    val density = LocalDensity.current
    return FontChecker(
        density = density,
        resourceLoader = LocalFontLoader.current,
        maxWidthPx = with (density) { maxWidth.toPx() },
        maxHeightPx = with (density) { maxHeight.toPx() },
        currentStyle = currentStyle,
        text = text
    )
}

private class FontChecker(
    private val density: Density,
    private val resourceLoader: Font.ResourceLoader,
    private val maxWidthPx: Float,
    private val maxHeightPx: Float,
    private val currentStyle: TextStyle,
    private val text: String
) {

    fun isFit(fontSizeSp: Float): Boolean {
        val height = Paragraph(
            text = text,
            style = currentStyle + TextStyle(fontSize = fontSizeSp.sp),
            width = maxWidthPx,
            density = density,
            resourceLoader = resourceLoader,
        ).height
        return height <= maxHeightPx
    }

    fun findMaxFittingTextSize(
        minTextSizeSp: Float,
        maxTextSizeSp: Float
    ) = if (!isFit(minTextSizeSp)) {
        minTextSizeSp.sp
    } else if (isFit(maxTextSizeSp)) {
        maxTextSizeSp.sp
    } else {
        var fit = minTextSizeSp
        var unfit = maxTextSizeSp
        while (unfit - fit > 1) {
            val current = fit + (unfit - fit) / 2
            if (isFit(current)) {
                fit = current
            } else {
                unfit = current
            }
        }
        fit.sp
    }
}

This is based on Mohammad's answer.

You have to find a better way to calculate the font size by using the box's height and message's length.

@Composable
fun Greeting() {
    var width by remember { mutableStateOf(0) }
    var height by remember { mutableStateOf(0) }
    val msg = "My really long long long long long text that needs to be resized to the height of this Column"
    Column(modifier = Modifier.height(150.dp).padding(8.dp).background(Color.Blue).onPositioned {
        width = it.size.width
        height = it.size.height
    }) {
        Log.d("mainactivity", "width = $width")
        Log.d("mainactivity", "height = $height")
        Text(
                modifier = Modifier.background(Color.Green).fillMaxHeight(),
                style = TextStyle(fontSize = calculateFontSize(msg, height).sp),
                text = msg
        )
    }
}

fun calculateFontSize(msg: String, height: Int): Int {
    return height / (msg.length / 5)
}
Related