I need to make Middle Ellipsis in Jetpack Compose Text. As far as I see there is only Clip, Ellipsis and Visible options for TextOverflow.
Something like this: 4gh45g43h...bh4bh6b64
It is not officially supported yet, keep an eye on this issue.
For now, you can use the following method. I use SubcomposeLayout to get onTextLayout result without actually drawing the initial text.
It takes so much code and calculations to:
Make sure the ellipsis is necessary, given all the modifiers applied to the text.
Make the size of the left and right parts as close to each other as possible, based on the size of the characters, not just their number.
#Composable
fun MiddleEllipsisText(
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,
softWrap: Boolean = true,
onTextLayout: (TextLayoutResult) -> Unit = {},
style: TextStyle = LocalTextStyle.current,
) {
// some letters, like "r", will have less width when placed right before "."
// adding a space to prevent such case
val layoutText = remember(text) { "$text $ellipsisText" }
val textLayoutResultState = remember(layoutText) {
mutableStateOf<TextLayoutResult?>(null)
}
SubcomposeLayout(modifier) { constraints ->
// result is ignored - we only need to fill our textLayoutResult
subcompose("measure") {
Text(
text = layoutText,
color = color,
fontSize = fontSize,
fontStyle = fontStyle,
fontWeight = fontWeight,
fontFamily = fontFamily,
letterSpacing = letterSpacing,
textDecoration = textDecoration,
textAlign = textAlign,
lineHeight = lineHeight,
softWrap = softWrap,
maxLines = 1,
onTextLayout = { textLayoutResultState.value = it },
style = style,
)
}.first().measure(Constraints())
// to allow smart cast
val textLayoutResult = textLayoutResultState.value
?: // shouldn't happen - onTextLayout is called before subcompose finishes
return#SubcomposeLayout layout(0, 0) {}
val placeable = subcompose("visible") {
val finalText = remember(text, textLayoutResult, constraints.maxWidth) {
if (text.isEmpty() || textLayoutResult.getBoundingBox(text.indices.last).right <= constraints.maxWidth) {
// text not including ellipsis fits on the first line.
return#remember text
}
val ellipsisWidth = layoutText.indices.toList()
.takeLast(ellipsisCharactersCount)
.let widthLet#{ indices ->
// fix this bug: https://issuetracker.google.com/issues/197146630
// in this case width is invalid
for (i in indices) {
val width = textLayoutResult.getBoundingBox(i).width
if (width > 0) {
return#widthLet width * ellipsisCharactersCount
}
}
// this should not happen, because
// this error occurs only for the last character in the string
throw IllegalStateException("all ellipsis chars have invalid width")
}
val availableWidth = constraints.maxWidth - ellipsisWidth
val startCounter = BoundCounter(text, textLayoutResult) { it }
val endCounter = BoundCounter(text, textLayoutResult) { text.indices.last - it }
while (availableWidth - startCounter.width - endCounter.width > 0) {
val possibleEndWidth = endCounter.widthWithNextChar()
if (
startCounter.width >= possibleEndWidth
&& availableWidth - startCounter.width - possibleEndWidth >= 0
) {
endCounter.addNextChar()
} else if (availableWidth - startCounter.widthWithNextChar() - endCounter.width >= 0) {
startCounter.addNextChar()
} else {
break
}
}
startCounter.string.trimEnd() + ellipsisText + endCounter.string.reversed().trimStart()
}
Text(
text = finalText,
color = color,
fontSize = fontSize,
fontStyle = fontStyle,
fontWeight = fontWeight,
fontFamily = fontFamily,
letterSpacing = letterSpacing,
textDecoration = textDecoration,
textAlign = textAlign,
lineHeight = lineHeight,
softWrap = softWrap,
onTextLayout = onTextLayout,
style = style,
)
}[0].measure(constraints)
layout(placeable.width, placeable.height) {
placeable.place(0, 0)
}
}
}
private const val ellipsisCharactersCount = 3
private const val ellipsisCharacter = '.'
private val ellipsisText = List(ellipsisCharactersCount) { ellipsisCharacter }.joinToString(separator = "")
private class BoundCounter(
private val text: String,
private val textLayoutResult: TextLayoutResult,
private val charPosition: (Int) -> Int,
) {
var string = ""
private set
var width = 0f
private set
private var _nextCharWidth: Float? = null
private var invalidCharsCount = 0
fun widthWithNextChar(): Float =
width + nextCharWidth()
private fun nextCharWidth(): Float =
_nextCharWidth ?: run {
var boundingBox: Rect
// invalidCharsCount fixes this bug: https://issuetracker.google.com/issues/197146630
invalidCharsCount--
do {
boundingBox = textLayoutResult
.getBoundingBox(charPosition(string.count() + ++invalidCharsCount))
} while (boundingBox.right == 0f)
_nextCharWidth = boundingBox.width
boundingBox.width
}
fun addNextChar() {
string += text[charPosition(string.count())]
width += nextCharWidth()
_nextCharWidth = null
}
}
My testing code:
val text = remember { LoremIpsum(100).values.first().replace("\n", " ") }
var length by remember { mutableStateOf(77) }
var width by remember { mutableStateOf(0.5f) }
Column {
MiddleEllipsisText(
text.take(length),
fontSize = 30.sp,
modifier = Modifier
.background(Color.LightGray)
.padding(10.dp)
.fillMaxWidth(width)
)
Slider(
value = length.toFloat(),
onValueChange = { length = it.roundToInt() },
valueRange = 2f..text.length.toFloat()
)
Slider(
value = width,
onValueChange = { width = it },
)
}
Result:
There is currently no specific function in Compose yet.
A possible approach is to process the string yourself before using it, with the kotlin functions.
val word = "4gh45g43hbh4bh6b64" //put your string here
val chunks = word.chunked((word.count().toDouble()/2).roundToInt())
val midEllipsis = "${chunks[0]}…${chunks[1]}"
println(midEllipsis)
I use the chunked function to divide the string into an array of strings, which will always be two because as a parameter I give it the size of the string divided by 2 and rounded up.
Result : 4gh45g43h…bh4bh6b64
To use the .roundToInt() function you need the following import
import kotlin.math.roundToInt
Test this yourself in the playground
Since TextView already supports ellipsize in the middle you can just wrap it in compose using AndroidView
AndroidView(
factory = { context ->
TextView(context).apply {
maxLines = 1
ellipsize = MIDDLE
}
},
update = { it.text = "A looooooooooong text" }
)
I'm struggling with vertically centering text in Jetpack Compose version alpha-11. It appears that my font has a significant amount of padding and I'm unable to find a way to disable it. This has come up only once before on SO, as far as I can tell, here, but their answer of using a constraint layout seems to suggest that they simply positioned it absolutely, which isn't exactly a solution as much as a workaround, and something I'd like to avoid.
You can see it clearly in the screenshot below.
The code for that looks like this:
Column(verticalArrangement = Arrangement.Center) {
Text(
text = "Let's Go",
color = Color.White,
fontSize = 120.sp,
fontFamily = oswaldLightFontFamily(),
textAlign = TextAlign.Center,
modifier = Modifier.background(Color.Blue)
)
}
The arguments you would expect to position it -- verticalArrangement and textAlign -- do not do anything here but I'm including them to demonstrate what I've tried.
My workaround so far has been to use Modifier.graphicsLayer(translationY = -25f) to shift it up but that seems like a terrible hack for something that should be so straightforward. It appears that in classic Android layouts, one could set android:includeFontPadding="false" and that would bypass this behavior but there doesn't seem to be a similar option in Jetpack Compose.
Anyone encounter this?
According to https://issuetracker.google.com/issues/171394808, It seems this is one of the limitations of the current JetPack Compose.
This is also deal breaker for my app because the font used rely heavily with the includeFontPadding. For current workaround, I create a CoreText that wraps TextView inside my compose.
Here's example of my wrapper, its not perfect but it does the job for my current use case:
#Composable
fun CoreText(
text: String,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
textDecoration: TextDecoration? = null,
textAlign: TextAlign? = null,
overflow: TextOverflow = TextOverflow.Clip,
maxLines: Int = Int.MAX_VALUE,
style: TextStyle = Typography.body2,
onClick: (() -> Unit)? = null,
) {
AndroidView(
modifier = modifier,
factory = { context ->
TextView(context)
},
update = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
it.setTextAppearance(style.fontWeight.toStyle())
} else {
it.setTextAppearance(it.context, style.fontWeight.toStyle())
}
if (overflow == TextOverflow.Ellipsis) {
it.ellipsize = TextUtils.TruncateAt.END
}
if (textDecoration != null) {
it.paintFlags = when (textDecoration) {
TextDecoration.Underline -> {
Paint.UNDERLINE_TEXT_FLAG
}
TextDecoration.LineThrough -> {
Paint.STRIKE_THRU_TEXT_FLAG
}
else -> 0
}
}
if (onClick != null) {
it.setOnClickListener { onClick.invoke() }
}
if (color != Color.Unspecified || style.color != Color.Unspecified) {
it.setTextColor(if (color == Color.Unspecified) style.color.toArgb() else color.toArgb())
}
it.textSize = style.fontSize.value
it.text = text
it.background = ColorDrawable(style.background.toArgb())
it.maxLines = maxLines
it.includeFontPadding = false
it.textAlignment = textAlign?.toStyle() ?: style.textAlign.toStyle()
}
)
}
// Replace with your style
fun FontWeight?.toStyle(): Int {
return when (this) {
FontWeight.Bold -> R.style.TextStyle_Bold
FontWeight.Normal -> R.style.TextStyle_Regular
FontWeight.Medium, FontWeight.SemiBold -> R.style.TextStyle_Medium
else -> -1
}
}
fun TextAlign?.toStyle(): Int {
return when (this) {
TextAlign.Left -> TEXT_ALIGNMENT_TEXT_START
TextAlign.Right -> TEXT_ALIGNMENT_TEXT_END
TextAlign.Center -> TEXT_ALIGNMENT_CENTER
TextAlign.Start -> TEXT_ALIGNMENT_TEXT_START
TextAlign.End -> TEXT_ALIGNMENT_TEXT_END
else -> -1
}
}
This happens due to uneven font padding on https://fonts.google.com/specimen/Oswald, plus the text you're using in lowercase makes the discrepancy more obvious.
As #Siyamed mentioned below, the API to turn the default includeFontPadding behaviour off in Compose was released with Compose 1.2 beta and you use it like so:
Text(
...
textStyle = TextStyle(
platformStyle = PlatformTextStyle(
includeFontPadding = false
),
)
https://android-developers.googleblog.com/2022/05/whats-new-in-jetpack-compose.html
give it a try, might help? Btw, the fact that PlatformTextStyle is "deprecated" only wants to inform that this is a compatibility API.
Compose now have TextStyle.platformStyle.incudeFontPadding that is set to true by default for version 1.2. you can set it to false in your TextStyle
Making the default false is something that Compose wants to do in v1.3 or later.
Just got around this same issue.
Box(contentAlignment = Alignment.Center){
Text(
text = "OK"
textAlign = TextAlign.Center
)
}
(Temporary) custom solution:
fun Modifier.baselinePadding(
firstBaselineToTop: Dp,
lastBaselineToBottom: Dp
) = layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
check(placeable[FirstBaseline] != AlignmentLine.Unspecified)
val firstBaseline = placeable[FirstBaseline]
check(placeable[LastBaseline] != AlignmentLine.Unspecified)
val lastBaseline = placeable[LastBaseline]
val lastBaselineToBottomHeight = placeable.height - lastBaseline
val lastBaselineToBottomDelta = lastBaselineToBottom.roundToPx() - lastBaselineToBottomHeight
val totalHeight = placeable.height +
(firstBaselineToTop.roundToPx() - firstBaseline)
val placeableY = totalHeight - placeable.height
layout(placeable.width, totalHeight + lastBaselineToBottomDelta) {
placeable.placeRelative(0, placeableY)
}
}
By using Compose 1.2.0-alpha07 and above, you can use PlatformTextStyle api to set includeFontPadding.
Try to the below code:
private val NotoSans = FontFamily(
Font(R.font.noto_san_jp_black, FontWeight.Black),
Font(R.font.noto_san_jp_light, FontWeight.Light),
Font(R.font.noto_san_jp_bold, FontWeight.Bold),
Font(R.font.noto_san_jp_thin, FontWeight.Thin),
Font(R.font.noto_san_jp_medium, FontWeight.Medium),
Font(R.font.noto_san_jp_regular, FontWeight.Normal),
)
val Typography = Typography(
headlineLarge = Typography().headlineLarge.copy(
fontFamily = NotoSans,
)
)
#OptIn(ExperimentalTextApi::class)
/* ... */
Text(
text = "地域のお得は\nすべてここに",
style = MaterialTheme.typography.headlineLarge.copy(
platformStyle = PlatformTextStyle(
includeFontPadding = false
)
/* ... */
)
)
The result when includeFontPadding = false:
The result when includeFontPadding = true or no using it:
More information:
Fixing Font Padding in Compose Text - Medium
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")
}
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
}
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
(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'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.
#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
)
}
}
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
}
}
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
)
}
}
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)
}
I had a need to implement autosizeable text with list of my own fonts.
So, here is my implementation based on Lit Climbing's answer