Text(
text = "Told lee he'd be a millionaire",
color = Color.White,
textDecoration = TextDecoration.LineThrough
)
The result of the above code is
I'd like to know if there's a way to change the color of the line that strikes through, The intended result would be something like
Workaround for this would be using Modifier.drawWithContent{} and drawing line in front of text. This is only for single line text though. If you need to use for multiple lines need to calculate text height and apply each line
Text(
text = "Told lee he'd be a millionaire",
color = Color.White,
fontSize = 24.sp,
textDecoration = TextDecoration.LineThrough
)
Text(
modifier = Modifier
.drawWithContent {
drawContent()
val strokeWidth = 2.dp.toPx()
val verticalCenter = size.height / 2 + 2 * strokeWidth
drawLine(
color = Color.Yellow,
strokeWidth = strokeWidth,
start = Offset(0f, verticalCenter),
end = Offset(size.width, verticalCenter)
)
},
fontSize = 24.sp,
text = "Told lee he'd be a millionaire",
color = Color.White
)
For multiline there can be many things to consider. But basically need to get line count, total height and width of each line.
var lineCount = 1
val lineList = mutableListOf<Float>()
Text(
onTextLayout = { textLayoutResult ->
lineCount = textLayoutResult.lineCount
for(i in 0 until lineCount) {
lineList.add(textLayoutResult.getLineRight(i))
}
},
modifier = Modifier
.drawWithContent {
drawContent()
val strokeWidth = 2.dp.toPx()
val center = size.height/(lineCount + 1)
for (i in 1..lineCount) {
val verticalCenter = i * center + 2 * strokeWidth
drawLine(
color = Color.Yellow,
strokeWidth = strokeWidth,
start = Offset(0f, verticalCenter),
end = Offset(lineList[i-1].toFloat(), verticalCenter)
)
}
},
fontSize = 40.sp,
text = "Told lee he'd be\n" +
"new line",
color = Color.White
)
Box {
var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
Text(
text = text,
modifier = modifier.drawWithContent {
drawContent()
if (isLineThrough) {
textLayoutResult?.apply {
val strokeWidth = 1.dp.toPx()
for (i in 0 until lineCount) {
val verticalCenter = (getLineTop(i) + ((getLineBottom(i) - getLineTop(i)) / 2)) + (2 * strokeWidth)
drawLine(
color = KDSColors.Red,
strokeWidth = strokeWidth,
start = Offset(-5f, verticalCenter),
end = Offset(getLineRight(i) + 5, verticalCenter)
)
}
}
}
},
onTextLayout = { textLayoutResult = it },
style = TextStyle.BlackBold14,
)
}
I had a BadgeView written with View using onMeasure, onLayout and OnDraw
I'm trying to migrate this View to Jetpack Compose.
Since drawing shapes is easier with compose i thought there is no need to use canvas or Layout functions at all, but size of Text or Surface wrapping it is not set properly before text size is calculated, and circle is not drawn properly.
Also checked out Badge component, it uses static sizes BadgeWithContentRadius, since in my design size depends on text size it's not possible to set a static size.
Surface(
shape = CircleShape,
contentColor = Color.White,
color = Color.Red
) {
Text(
text = "0",
modifier = Modifier.padding(4.dp),
fontSize = 34.sp,
)
}
Then tried using
var size: Dp by remember { mutableStateOf(40.dp) }
val density = LocalDensity.current
Surface(
shape = CircleShape,
modifier = Modifier.size(size),
contentColor = Color.Yellow,
color = Color.Red
){
Text(
text = "0",
modifier = Modifier.padding(4.dp),
fontSize = 24.sp,
onTextLayout = { textLayoutResult: TextLayoutResult ->
val textSize = textLayoutResult.size
val circleRadius = textSize.width.coerceAtLeast(textSize.height)
size = with(density) {
circleRadius.toDp()
}
println("Size: $size")
}
)
}
Both of the implementations are not working, then tried doing it with Layout
#Composable
private fun Badge(text: String, badgeState: BadgeState, modifier: Modifier = Modifier) {
Surface(shape = CircleShape, color = Color.Red, contentColor = Color.White) {
BadgeLayout(text = text, badgeState = badgeState, modifier = modifier)
}
}
#Composable
private fun BadgeLayout(text: String, badgeState: BadgeState, modifier: Modifier = Modifier) {
var circleRadius = 0
var size: IntSize by remember {
mutableStateOf(IntSize(0, 0))
}
val content = #Composable {
Text(
text = text,
modifier = Modifier.padding(4.dp),
fontSize = 34.sp,
onTextLayout = { textLayoutResult: TextLayoutResult ->
size = textLayoutResult.size
circleRadius = size.width.coerceAtLeast(size.height)
},
)
}
Layout(
modifier = modifier,
content = content
) { measurables: List<Measurable>, constraints: Constraints ->
val placeables = measurables.map { measurable ->
measurable.measure(constraints)
}
println("🔥 Badge: $circleRadius, size: $size")
layout(width = circleRadius, height = circleRadius) {
placeables.first().placeRelative(0, 0)
}
}
}
Shape seems to be applied correctly but couldn't find exact way to get text size to set number to center of Surface or Text.
How can a component, should have circle shape when it's one or digit number then turning it into RoundedCornerShape can be implemented with considering performance be implemented?
I've made the following modifier using Modifier.layout:
fun Modifier.badgeLayout() =
layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
// based on the expectation of only one line of text
val minPadding = placeable.height / 4
val width = maxOf(placeable.width + minPadding, placeable.height)
layout(width, placeable.height) {
placeable.place((width - placeable.width) / 2, 0)
}
}
Usage:
Text(
text,
modifier = Modifier
.background(MaterialTheme.colors.error, shape = CircleShape)
.badgeLayout()
)
Result:
I would look into using the Material Badge that is already available for Compose:
Material Badge for Compose
Solution is to use textHeight with onTextLayout callback of Text. Since placeable.height returns full text height with font padding
onTextLayout = { textLayoutResult: TextLayoutResult ->
textSize = textLayoutResult.size
// 🔥🔥 This is text height without padding, result size returns height with font padding
textHeight = textLayoutResult.firstBaseline.toInt()
}
Layout implementation is as
#Composable
fun Badge(
modifier: Modifier = Modifier,
badgeState: BadgeState = rememberBadgeState(),
) {
BadgeComponent(badgeState = badgeState, modifier = modifier)
}
#Composable
private fun BadgeComponent(badgeState: BadgeState, modifier: Modifier = Modifier) {
// TODO Question: Why does this not survive recompositions without mutableState?
var textSize = remember { IntSize(0, 0) }
var textHeight = remember(badgeState) { 0 }
var badgeHeight = remember { 0 }
val density = LocalDensity.current
val text = badgeState.text
val isCircleShape = badgeState.isCircleShape
val shape =
if (isCircleShape) CircleShape else RoundedCornerShape(badgeState.roundedRadiusPercent)
println(
"✅ BadgeComponent: text: $text, " +
"isCircleShape: $isCircleShape, " +
"textHeight: $textHeight, " +
"badgeHeight: $badgeHeight, " +
"textSize: $textSize"
)
val content = #Composable {
Text(
text = badgeState.text,
color = badgeState.textColor,
fontSize = badgeState.fontSize,
lineHeight = badgeState.fontSize,
onTextLayout = { textLayoutResult: TextLayoutResult ->
textSize = textLayoutResult.size
// 🔥🔥 This is text height without padding, result size returns height with font padding
textHeight = textLayoutResult.firstBaseline.toInt()
println("✏️ BadgeComponent textHeight: $textHeight, textSize: $textSize")
},
)
}
val badgeModifier = modifier
.materialShadow(badgeState = badgeState)
.then(
badgeState.borderStroke?.let { borderStroke ->
modifier.border(borderStroke, shape = shape)
} ?: modifier
)
.background(
badgeState.backgroundColor,
shape = shape
)
Layout(
modifier = badgeModifier,
content = content
) { measurables: List<Measurable>, constraints: Constraints ->
val placeables = measurables.map { measurable ->
measurable.measure(constraints)
}
val placeable = placeables.first()
if (badgeHeight == 0) {
// Space above and below text, this is drawing area + empty space
val verticalSpaceAroundText = with(density) {
textHeight * .12f + 6 + badgeState.verticalPadding.toPx()
}
badgeHeight = (textHeight + 2 * verticalSpaceAroundText).toInt()
if (isCircleShape) {
// Use bigger dimension to have circle that covers 2 digit counts either
badgeHeight = textSize.width.coerceAtLeast(badgeHeight)
layout(width = badgeHeight, height = badgeHeight) {
placeable.placeRelative(
(badgeHeight - textSize.width) / 2,
(badgeHeight - textSize.height) / 2
)
}
} else {
// Space left and right of the text, this is drawing area + empty space
val horizontalSpaceAroundText = with(density) {
textHeight * .12f + 6 + badgeState.horizontalPadding.toPx()
}
val width = (textSize.width + 2 * horizontalSpaceAroundText).toInt()
layout(width = width, height = badgeHeight) {
placeable.placeRelative(
x = (width - textSize.width) / 2,
y = (-textSize.height + badgeHeight) / 2
)
}
}
}
}
Also used custom Modifier and rememberable to set colored shadow, paddings, font properties, shapes and more which full implementation can be found in github repository.
Final result
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?
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:
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(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.
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
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
)
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.
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,
)
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