Skip to content

Instantly share code, notes, and snippets.

@inidamleader
Last active April 26, 2024 12:01
Show Gist options
  • Star 37 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save inidamleader/b594d35362ebcf3cedf81055df519300 to your computer and use it in GitHub Desktop.
Save inidamleader/b594d35362ebcf3cedf81055df519300 to your computer and use it in GitHub Desktop.
Composable function that automatically adjusts the text size to fit within given constraints with optimal performance by using a binary search algorithm
// LAST UPDATE: 10 April 2024 v4.3:
// - Correction of updated density value in AutoSizeText function
package com.inidamleader.ovtracker.util.compose
import android.util.Log
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.BoxWithConstraintsScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.InternalFoundationTextApi
import androidx.compose.foundation.text.TextDelegate
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFontFamilyResolver
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.isSpecified
import androidx.compose.ui.unit.sp
import com.inidamleader.ovtracker.util.compose.SuggestedFontSizesStatus.Companion.rememberSuggestedFontSizesStatus
import com.inidamleader.ovtracker.util.compose.geometry.roundToPx
import com.inidamleader.ovtracker.util.compose.geometry.toIntSize
import com.inidamleader.ovtracker.util.compose.geometry.toSp
import kotlin.math.min
/**
* Composable function that automatically adjusts the text size to fit within given constraints, considering the ratio of line spacing to text size.
*
* Features:
* 1. Best performance: Utilizes a dichotomous binary search algorithm for swift and optimal text size determination without unnecessary iterations.
* 2. Alignment support: Supports six possible alignment values via the Alignment interface.
* 3. Material Design 3 support.
* 4. Font scaling support: User-initiated font scaling doesn't affect the visual rendering output.
* 5. Multiline Support with maxLines Parameter.
*
* @param text the text to be displayed
* @param modifier the [Modifier] to be applied to this layout node
* @param color [Color] to apply to the text. If [Color.Unspecified], and [style] has no color set,
* this will be [LocalContentColor].
* @param suggestedFontSizes The suggested font sizes to choose from (Should be sorted from smallest to largest, not empty and contains only sp text unit).
* @param suggestedFontSizesStatus Whether or not suggestedFontSizes is valid: not empty - contains oly sp text unit - sorted.
* You can check validity by invoking [List<TextUnit>.suggestedFontSizesStatus]
* @param stepGranularityTextSize The step size for adjusting the text size. this parameter is ignored if [suggestedFontSizes] is specified and [suggestedFontSizesStatus] is [SuggestedFontSizesStatus.VALID].
* @param minTextSize The minimum text size allowed. this parameter is ignored if [suggestedFontSizes] is specified or [suggestedFontSizesStatus] is [SuggestedFontSizesStatus.VALID].
* @param maxTextSize The maximum text size allowed.
* @param fontStyle the typeface variant to use when drawing the letters (e.g., italic).
* See [TextStyle.fontStyle].
* @param fontWeight the typeface thickness to use when painting the text (e.g., [FontWeight.Bold]).
* @param fontFamily the font family to be used when rendering the text. See [TextStyle.fontFamily].
* @param letterSpacing the amount of space to add between each letter.
* See [TextStyle.letterSpacing].
* @param textDecoration the decorations to paint on the text (e.g., an underline).
* See [TextStyle.textDecoration].
* @param alignment The alignment of the text within its container.
* @param overflow how visual overflow should be handled.
* @param softWrap whether the text should break at soft line breaks. If false, the glyphs in the
* text will be positioned as if there was unlimited horizontal space. If [softWrap] is false,
* [overflow] and TextAlign may have unexpected effects.
* @param maxLines An optional maximum number of lines for the text to span, wrapping if
* necessary. If the text exceeds the given number of lines, it will be truncated according to
* [overflow] and [softWrap]. It is required that 1 <= [minLines] <= [maxLines].
* @param minLines The minimum height in terms of minimum number of visible lines. It is required
* that 1 <= [minLines] <= [maxLines].
* insert composables into text layout. See [InlineTextContent].
* @param onTextLayout callback that is executed when a new text layout is calculated. A
* [TextLayoutResult] object that callback provides contains paragraph information, size of the
* text, baselines and other details. The callback can be used to add additional decoration or
* functionality to the text. For example, to draw selection around the text.
* @param style style configuration for the text such as color, font, line height etc.
* @param lineSpacingRatio The ratio of line spacing to text size.
*
* @author Reda El Madini - For support, contact gladiatorkilo@gmail.com
*/
@Composable
fun AutoSizeText(
text: String,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
suggestedFontSizes: ImmutableWrapper<List<TextUnit>> = emptyList<TextUnit>().toImmutableWrapper(),
suggestedFontSizesStatus: SuggestedFontSizesStatus = suggestedFontSizes.rememberSuggestedFontSizesStatus,
stepGranularityTextSize: TextUnit = TextUnit.Unspecified,
minTextSize: TextUnit = TextUnit.Unspecified,
maxTextSize: TextUnit = TextUnit.Unspecified,
fontStyle: FontStyle? = null,
fontWeight: FontWeight? = null,
fontFamily: FontFamily? = null,
letterSpacing: TextUnit = TextUnit.Unspecified,
textDecoration: TextDecoration? = null,
alignment: Alignment = Alignment.TopStart,
overflow: TextOverflow = TextOverflow.Clip,
softWrap: Boolean = true,
maxLines: Int = Int.MAX_VALUE,
minLines: Int = 1,
onTextLayout: (TextLayoutResult) -> Unit = {},
style: TextStyle = LocalTextStyle.current,
lineSpacingRatio: Float = style.lineHeight.value / style.fontSize.value,
) {
AutoSizeText(
text = AnnotatedString(text),
modifier = modifier,
color = color,
suggestedFontSizes = suggestedFontSizes,
suggestedFontSizesStatus = suggestedFontSizesStatus,
stepGranularityTextSize = stepGranularityTextSize,
minTextSize = minTextSize,
maxTextSize = maxTextSize,
fontStyle = fontStyle,
fontWeight = fontWeight,
fontFamily = fontFamily,
letterSpacing = letterSpacing,
textDecoration = textDecoration,
alignment = alignment,
overflow = overflow,
softWrap = softWrap,
maxLines = maxLines,
minLines = minLines,
onTextLayout = onTextLayout,
style = style,
lineSpacingRatio = lineSpacingRatio,
)
}
/**
* Composable function that automatically adjusts the text size to fit within given constraints using AnnotatedString, considering the ratio of line spacing to text size.
*
* Features:
* Similar to AutoSizeText(String), with support for AnnotatedString.
*
* @param inlineContent a map storing composables that replaces certain ranges of the text, used to
* insert composables into text layout. See [InlineTextContent].
* @see AutoSizeText
*/
@Composable
fun AutoSizeText(
text: AnnotatedString,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
suggestedFontSizes: ImmutableWrapper<List<TextUnit>> = emptyList<TextUnit>().toImmutableWrapper(),
suggestedFontSizesStatus: SuggestedFontSizesStatus = suggestedFontSizes.rememberSuggestedFontSizesStatus,
stepGranularityTextSize: TextUnit = TextUnit.Unspecified,
minTextSize: TextUnit = TextUnit.Unspecified,
maxTextSize: TextUnit = TextUnit.Unspecified,
fontStyle: FontStyle? = null,
fontWeight: FontWeight? = null,
fontFamily: FontFamily? = null,
letterSpacing: TextUnit = TextUnit.Unspecified,
textDecoration: TextDecoration? = null,
alignment: Alignment = Alignment.TopStart,
overflow: TextOverflow = TextOverflow.Clip,
softWrap: Boolean = true,
maxLines: Int = Int.MAX_VALUE,
minLines: Int = 1,
inlineContent: ImmutableWrapper<Map<String, InlineTextContent>> = mapOf<String, InlineTextContent>().toImmutableWrapper(),
onTextLayout: (TextLayoutResult) -> Unit = {},
style: TextStyle = LocalTextStyle.current,
lineSpacingRatio: Float = style.lineHeight.value / style.fontSize.value,
) {
// Change font scale to 1F
CompositionLocalProvider(
LocalDensity provides Density(density = LocalDensity.current.density, fontScale = 1F)
) {
BoxWithConstraints(
modifier = modifier,
contentAlignment = alignment,
) {
val combinedTextStyle = LocalTextStyle.current + style.copy(
color = color.takeIf { it.isSpecified } ?: style.color,
fontStyle = fontStyle ?: style.fontStyle,
fontWeight = fontWeight ?: style.fontWeight,
fontFamily = fontFamily ?: style.fontFamily,
letterSpacing = letterSpacing.takeIf { it.isSpecified } ?: style.letterSpacing,
textDecoration = textDecoration ?: style.textDecoration,
textAlign = when (alignment) {
Alignment.TopStart, Alignment.CenterStart, Alignment.BottomStart -> TextAlign.Start
Alignment.TopCenter, Alignment.Center, Alignment.BottomCenter -> TextAlign.Center
Alignment.TopEnd, Alignment.CenterEnd, Alignment.BottomEnd -> TextAlign.End
else -> TextAlign.Unspecified
},
)
val layoutDirection = LocalLayoutDirection.current
val density = LocalDensity.current
val fontFamilyResolver = LocalFontFamilyResolver.current
val coercedLineSpacingRatio = lineSpacingRatio.takeIf { it.isFinite() && it >= 1 } ?: 1F
val shouldMoveBackward: (TextUnit) -> Boolean = {
shouldShrink(
text = text,
textStyle = combinedTextStyle.copy(
fontSize = it,
lineHeight = it * coercedLineSpacingRatio,
),
minLines = minLines,
maxLines = maxLines,
softWrap = softWrap,
layoutDirection = layoutDirection,
density = density,
fontFamilyResolver = fontFamilyResolver,
)
}
val electedFontSize = kotlin.run {
if (suggestedFontSizesStatus == SuggestedFontSizesStatus.VALID)
suggestedFontSizes.value
else
remember(key1 = suggestedFontSizes) {
suggestedFontSizes.value
.filter { it.isSp }
.takeIf { it.isNotEmpty() }
?.sortedBy { it.value }
}
}
?.findElectedValue(shouldMoveBackward = shouldMoveBackward)
?: rememberCandidateFontSizesIntProgress(
density = density,
dpSize = DpSize(maxWidth, maxHeight),
maxTextSize = maxTextSize,
minTextSize = minTextSize,
stepGranularityTextSize = stepGranularityTextSize,
).findElectedValue(
transform = { density.toSp(it) },
shouldMoveBackward = shouldMoveBackward,
)
if (electedFontSize == 0.sp)
Log.w(
"AutoSizeText",
"""The text cannot be displayed. Please consider the following options:
| 1. Providing 'suggestedFontSizes' with smaller values that can be utilized.
| 2. Decreasing the 'stepGranularityTextSize' value.
| 3. Adjusting the 'minTextSize' parameter to a suitable value and ensuring the overflow parameter is set to "TextOverflow.Ellipsis".
""".trimMargin()
)
Text(
text = text,
overflow = overflow,
softWrap = softWrap,
maxLines = maxLines,
minLines = minLines,
inlineContent = inlineContent.value,
onTextLayout = onTextLayout,
style = combinedTextStyle.copy(
fontSize = electedFontSize,
lineHeight = electedFontSize * coercedLineSpacingRatio,
),
)
}
}
}
@OptIn(InternalFoundationTextApi::class)
private fun BoxWithConstraintsScope.shouldShrink(
text: AnnotatedString,
textStyle: TextStyle,
minLines: Int,
maxLines: Int,
softWrap: Boolean,
layoutDirection: LayoutDirection,
density: Density,
fontFamilyResolver: FontFamily.Resolver,
) = TextDelegate(
text = text,
style = textStyle,
maxLines = maxLines,
minLines = minLines,
softWrap = softWrap,
overflow = TextOverflow.Clip,
density = density,
fontFamilyResolver = fontFamilyResolver,
).layout(
constraints = constraints,
layoutDirection = layoutDirection,
).hasVisualOverflow
private fun BoxWithConstraintsScope.shouldShrink2(
text: AnnotatedString,
textStyle: TextStyle,
maxLines: Int,
layoutDirection: LayoutDirection,
softWrap: Boolean,
density: Density,
fontFamilyResolver: FontFamily.Resolver,
textMeasurer: TextMeasurer,
) = textMeasurer.measure(
text = text,
style = textStyle,
overflow = TextOverflow.Clip,
softWrap = softWrap,
maxLines = maxLines,
constraints = constraints,
layoutDirection = layoutDirection,
density = density,
fontFamilyResolver = fontFamilyResolver,
).hasVisualOverflow
@Composable
private fun rememberCandidateFontSizesIntProgress(
density: Density,
dpSize: DpSize,
minTextSize: TextUnit = TextUnit.Unspecified,
maxTextSize: TextUnit = TextUnit.Unspecified,
stepGranularityTextSize: TextUnit = TextUnit.Unspecified,
): IntProgression {
val max = remember(key1 = maxTextSize, key2 = dpSize, key3 = density) {
val intSize = density.toIntSize(dpSize)
min(intSize.width, intSize.height).let { max ->
maxTextSize
.takeIf { it.isSp }
?.let { density.roundToPx(it) }
?.coerceIn(range = 0..max)
?: max
}
}
val min = remember(key1 = minTextSize, key2 = max, key3 = density) {
minTextSize
.takeIf { it.isSp }
?.let { density.roundToPx(it) }
?.coerceIn(range = 0..max)
?: 0
}
val step = remember(
stepGranularityTextSize,
min,
max,
density,
) {
stepGranularityTextSize
.takeIf { it.isSp }
?.let { density.roundToPx(it) }
?.coerceIn(minimumValue = 1, maximumValue = max - min)
?: 1
}
return remember(key1 = min, key2 = max, key3 = step) {
min..max step step
}
}
// This function works by using a binary search algorithm
fun <E> List<E>.findElectedValue(shouldMoveBackward: (E) -> Boolean) = run {
indices.findElectedValue(
transform = { this[it] },
shouldMoveBackward = shouldMoveBackward,
)
}
// This function works by using a binary search algorithm
private fun <E> IntProgression.findElectedValue(
transform: (Int) -> E,
shouldMoveBackward: (E) -> Boolean,
) = run {
var low = first / step
var high = last / step
while (low <= high) {
val mid = low + (high - low) / 2
if (shouldMoveBackward(transform(mid * step)))
high = mid - 1
else
low = mid + 1
}
transform((high * step).coerceAtLeast(minimumValue = first * step))
}
enum class SuggestedFontSizesStatus {
VALID, INVALID, UNKNOWN;
companion object {
val List<TextUnit>.suggestedFontSizesStatus
get() = if (isNotEmpty() && all { it.isSp } && sortedBy { it.value } == this)
VALID
else
INVALID
val ImmutableWrapper<List<TextUnit>>.rememberSuggestedFontSizesStatus
@Composable get() = remember(key1 = this) { value.suggestedFontSizesStatus }
}
}
@Preview(widthDp = 200, heightDp = 100)
@Preview(widthDp = 200, heightDp = 30)
@Preview(widthDp = 60, heightDp = 30)
@Composable
fun PreviewAutoSizeTextWithMaxLinesSetToIntMaxValue() {
MaterialTheme {
Surface(color = MaterialTheme.colorScheme.primary) {
AutoSizeText(
text = "This is a bunch of text that will be auto sized",
modifier = Modifier.fillMaxSize(),
alignment = Alignment.CenterStart,
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
@Preview(widthDp = 200, heightDp = 100)
@Preview(widthDp = 200, heightDp = 30)
@Preview(widthDp = 60, heightDp = 30)
@Composable
fun PreviewAutoSizeTextWithMinSizeSetTo14() {
MaterialTheme {
Surface(color = MaterialTheme.colorScheme.secondary) {
AutoSizeText(
text = "This is a bunch of text that will be auto sized",
modifier = Modifier.fillMaxSize(),
minTextSize = 14.sp,
alignment = Alignment.CenterStart,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
@Preview(widthDp = 200, heightDp = 100)
@Preview(widthDp = 200, heightDp = 30)
@Preview(widthDp = 60, heightDp = 30)
@Composable
fun PreviewAutoSizeTextWithMaxLinesSetToOne() {
MaterialTheme {
Surface(color = MaterialTheme.colorScheme.tertiary) {
AutoSizeText(
text = "This is a bunch of text that will be auto sized",
modifier = Modifier.fillMaxSize(),
alignment = Alignment.Center,
maxLines = 1,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
@Preview(widthDp = 100, heightDp = 50)
@Preview(widthDp = 50, heightDp = 100)
@Composable
fun PreviewAutoSizeTextWithMCharacter() {
MaterialTheme {
Surface(color = MaterialTheme.colorScheme.error) {
AutoSizeText(
text = "m",
modifier = Modifier.fillMaxSize(),
alignment = Alignment.Center,
style = MaterialTheme.typography.bodyMedium,
lineSpacingRatio = 1F,
)
}
}
}
@Preview(widthDp = 100, heightDp = 50)
@Preview(widthDp = 50, heightDp = 100)
@Composable
fun PreviewAutoSizeTextWithYCharacter() {
MaterialTheme {
Surface(color = MaterialTheme.colorScheme.error) {
AutoSizeText(
text = "y",
modifier = Modifier.fillMaxSize(),
alignment = Alignment.Center,
style = MaterialTheme.typography.bodyMedium.copy(
lineHeightStyle = LineHeightStyle(
alignment = LineHeightStyle.Alignment.Center,
trim = LineHeightStyle.Trim.Both,
)
),
)
}
}
}
package com.inidamleader.ovtracker.util.compose
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
import com.inidamleader.ovtracker.util.compose.SuggestedFontSizesStatus.Companion.suggestedFontSizesStatus
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertFails
import kotlin.test.assertTrue
class AutoSizeTextTest {
@Test
fun findElectedIndexThrowException() {
List(size = 0) { it }.run {
assertFails { findElectedValue { false } }
}
List(size = 0) { it }.run {
assertFails { findElectedValue { true } }
}
}
@Test
fun findElectedValueAt0() {
List(size = 1) { it }.run {
assertEquals(
expected = 0,
actual = findElectedValue { false },
)
}
List(size = 1) { it }.run {
assertEquals(
expected = 0,
actual = findElectedValue { true },
)
}
List(size = 2) { it }.run {
assertEquals(
expected = 0,
actual = findElectedValue { true },
)
}
List(size = 15) { it }.run {
assertEquals(
expected = 0,
actual = findElectedValue { true },
)
}
List(size = 16) { it }.run {
assertEquals(
expected = 0,
actual = findElectedValue { true },
)
}
}
@Test
fun findElectedValueAt1() {
List(size = 2) { it }.run {
assertEquals(
expected = 1,
actual = findElectedValue { false },
)
}
List(size = 2) { it }.run {
assertEquals(
expected = 1,
actual = findElectedValue { it > 1 },
)
}
List(size = 15) { it }.run {
assertEquals(
expected = 1,
actual = findElectedValue { it > 1 },
)
}
List(size = 16) { it }.run {
assertEquals(
expected = 1,
actual = findElectedValue { it > 1 },
)
}
}
@Test
fun findElectedValueAt2() {
List(size = 3) { it }.run {
assertEquals(
expected = 2,
actual = findElectedValue { false },
)
}
List(size = 14) { it }.run {
assertEquals(
expected = 2,
actual = findElectedValue { it > 2 },
)
}
List(size = 15) { it }.run {
assertEquals(
expected = 2,
actual = findElectedValue { it > 2 },
)
}
List(size = 16) { it }.run {
assertEquals(
expected = 2,
actual = findElectedValue { it > 2 },
)
}
}
@Test
fun findElectedValueAt9() {
List(size = 10) { it }.run {
assertEquals(
expected = 9,
actual = findElectedValue { false },
)
}
List(size = 14) { it }.run {
assertEquals(
expected = 9,
actual = findElectedValue { it > 9 },
)
}
List(size = 15) { it }.run {
assertEquals(
expected = 9,
actual = findElectedValue { it > 9 },
)
}
List(size = 16) { it }.run {
assertEquals(
expected = 9,
actual = findElectedValue { it > 9 },
)
}
}
@Test
fun findElectedValueAt10() {
List(size = 11) { it }.run {
assertEquals(
expected = 10,
actual = findElectedValue { false },
)
}
List(size = 14) { it }.run {
assertEquals(
expected = 10,
actual = findElectedValue { it > 10 },
)
}
List(size = 15) { it }.run {
assertEquals(
expected = 10,
actual = findElectedValue { it > 10 },
)
}
List(size = 16) { it }.run {
assertEquals(
expected = 10,
actual = findElectedValue { it > 10 },
)
}
}
@Test
fun suggestedFontSizesStatus() {
assertTrue {
listOf(1.sp, 2.sp, 4.sp).suggestedFontSizesStatus == SuggestedFontSizesStatus.VALID
}
assertTrue {
listOf(2.sp, 1.sp, 4.sp).suggestedFontSizesStatus == SuggestedFontSizesStatus.INVALID
}
assertTrue {
emptyList<TextUnit>().suggestedFontSizesStatus == SuggestedFontSizesStatus.INVALID
}
assertTrue {
listOf(1.sp, 2.em, 4.sp).suggestedFontSizesStatus == SuggestedFontSizesStatus.INVALID
}
}
}
package com.inidamleader.ovtracker.util.compose.geometry
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.geometry.isSpecified
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.isSpecified
// DP
fun Density.toSp(dp: Dp): TextUnit = dp.toSp()
fun Density.toPx(dp: Dp): Float = dp.toPx()
fun Density.roundToPx(dp: Dp): Int = dp.roundToPx()
// TEXT UNIT
fun Density.toDp(sp: TextUnit): Dp = sp.toDp()
fun Density.toPx(sp: TextUnit): Float = sp.toPx()
fun Density.roundToPx(sp: TextUnit): Int = sp.roundToPx()
// FLOAT
fun Density.toDp(px: Float): Dp = px.toDp()
fun Density.toSp(px: Float): TextUnit = px.toSp()
// INT
fun Density.toDp(px: Int): Dp = px.toDp()
fun Density.toSp(px: Int): TextUnit = px.toSp()
// SIZE
fun Density.toIntSize(dpSize: DpSize): IntSize =
IntSize(dpSize.width.roundToPx(), dpSize.height.roundToPx())
fun Density.toSize(dpSize: DpSize): Size =
if (dpSize.isSpecified) Size(dpSize.width.toPx(), dpSize.height.toPx())
else Size.Unspecified
fun Density.toDpSize(size: Size): DpSize =
if (size.isSpecified) DpSize(size.width.toDp(), size.height.toDp())
else DpSize.Unspecified
fun Density.toDpSize(intSize: IntSize): DpSize =
DpSize(intSize.width.toDp(), intSize.height.toDp())
// OFFSET
fun Density.toIntOffset(dpOffset: DpOffset): IntOffset =
IntOffset(dpOffset.x.roundToPx(), dpOffset.y.roundToPx())
fun Density.toOffset(dpOffset: DpOffset): Offset =
if (dpOffset.isSpecified) Offset(dpOffset.x.toPx(), dpOffset.y.toPx())
else Offset.Unspecified
fun Density.toDpOffset(offset: Offset): DpOffset =
if (offset.isSpecified) DpOffset(offset.x.toDp(), offset.y.toDp())
else DpOffset.Unspecified
fun Density.toDpOffset(intOffset: IntOffset): DpOffset =
DpOffset(intOffset.x.toDp(), intOffset.y.toDp())
package com.inidamleader.ovtracker.util.compose
import androidx.compose.runtime.Immutable
import kotlin.reflect.KProperty
@Immutable
data class ImmutableWrapper<T>(val value: T)
fun <T> T.toImmutableWrapper() = ImmutableWrapper(this)
operator fun <T> ImmutableWrapper<T>.getValue(thisRef: Any?, property: KProperty<*>) = value
@inidamleader
Copy link
Author

inidamleader commented Jan 7, 2024

@sergeych I added a check condition in case the user passes a wrong lineSpacingRatio argument, to ensure that lineHeight is always positive and greater than fontSize:
val coercedLineSpacingRatio = lineSpacingRatio.coerceAtLeast(1f)

@inidamleader
Copy link
Author

There are some unused functions in DensityExt.kt file, don't forget to remove them

@sergeych
Copy link

sergeych commented Jan 9, 2024 via email

@sergibc
Copy link

sergibc commented Jan 9, 2024

@inidamleader lineSpacingRatio might be NaN too, it could be fixed with this:
val coercedLineSpacingRatio = lineSpacingRatio.coerceAtLeast(1f).takeIf { !lineSpacingRatio.isNaN() } ?: 1f

@inidamleader
Copy link
Author

inidamleader commented Jan 9, 2024

@sergibc Yes, good idea, thank you. Update done

@inidamleader
Copy link
Author

inidamleader commented Jan 9, 2024

I've also written an article about this function on Medium and a post on StackOverflow.

If you find it helpful, please applaud and upvote to reach more developers.

@inidamleader
Copy link
Author

previews

@ahmedhosnypro
Copy link

Sadly Can't use it in Lazy Layouts

@inidamleader
Copy link
Author

@ahmedhosnypro It should work, can you share a code snippet?

@daniel-2peaches
Copy link

Works like a charm. Thanks a lot!

@ryanholden8
Copy link

@ryanholden8
Copy link

One thing that surprised me when using this component is that it made text super large when just renaming Text to AutoSizeText. The text already had plenty of space to render at the current font size.

As a suggestion, if the typical use case is to decrease the font size to fit within a space only when there is not enough space, then defaulting maxTextSize to the style.fontSize may be the typically expected behavior. And maybe even a good min font size default?

@Composable
fun AutoSizeText(
    style: TextStyle = LocalTextStyle.current,
    // The smallest legible font for M3
    minTextSize: TextUnit = MaterialTheme.typography.labelSmall.fontSize,
    // By default don't increase the font size
    maxTextSize: TextUnit = style.fontSize,
) {

@ryanholden8
Copy link

ryanholden8 commented Feb 9, 2024

This component crashes the app when it has a parent that uses Modifier.height(IntrinsicSize.Min). This is a common modifier when working with lazy columns. Probably what @ahmedhosnypro was also experiencing. Thanks for the hard work on this component! Looks really promising and needed 😄

Exception That Crashes App

java.lang.IllegalStateException: Asking for intrinsic measurements of SubcomposeLayout layouts is not supported. This includes components that are built on top of SubcomposeLayout, such as lazy lists, BoxWithConstraints, TabRow, etc. To mitigate this:
- if intrinsic measurements are used to achieve 'match parent' sizing, consider replacing the parent of the component with a custom layout which controls the order in which children are measured, making intrinsic measurement not needed
- adding a size modifier to the component, in order to fast return the queried intrinsic measurement.
at androidx.compose.ui.node.LayoutNode$NoIntrinsicsMeasurePolicy.minIntrinsicHeight(LayoutNode.kt:615)

Reproducible Code Everytime

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview

@Composable
@Preview
fun Test() {
    Column(
        modifier = Modifier.height(IntrinsicSize.Min)
    ) {
        AutoSizeText(
            text = "ksdnjkjjndakjnfknjdfnjkfgdaklgfdklgfdkldflkgfdlkagdflkgfljkdljkdfgaksdnjkjjndakjnfknjdfnjkfgdaklgfdklgfdkldflkgfdlkagdflkgfljkdljkdfga",
        )
    }
}

@inidamleader
Copy link
Author

inidamleader commented Feb 9, 2024

@ryanholden8 thank you for your help:
1- The small typo has been rectified.
2- Regarding AutoSizeText: its primary function is to utilize the entire available space when maxSize isn't specified.
3- Regarding the IllegalStateException issue, it appears to be unrelated to AutoSizeText.

@jtrollkarl
Copy link

@inidamleader
Is there any way to prevent single characters, within a string, from taking up more than 1 line?

@cvb941
Copy link

cvb941 commented Apr 5, 2024

The TextDelegate class seems to be internal now in Compose...

@inidamleader
Copy link
Author

@jtrollkarl I'm not entirely sure I understand your concern about preventing single characters within a string from taking up more than one line. Could you please provide a code snippet or a more detailed explanation? It would help me better grasp the issue and provide a more informed response. Thanks for bringing this up!

@inidamleader
Copy link
Author

@cvb941 Thank you for bringing this up! Could you please specify which version of Compose you're referring to?

@brAzzi64
Copy link

brAzzi64 commented Apr 6, 2024

I'm seeing this as well on:

implementation(platform("androidx.compose:compose-bom:2023.08.00"))

"Internal/Unstable API for use only between foundation modules sharing the same exact version, subject to change without notice."

@hippietrail
Copy link

Does this component only reduce text that's too big, or does it also magnify it if it's too small? The text only seems to say 'fit' which could mean either or both.

I'm actually looking for something to help make text fill its container for maximum readability for people with imperfect eyesight. The most popular app I know that does this is Google Translate.

I would just try it but a) it would be great to learn just from a glance by adding it to the description and the comments here, and b) I've been coding on other platforms for months and switching back to Android and Android Studio is always much more effort than switching back to any other platform I've been away from for a while (-:

If it doesn't also magnify then consider this a feature request.

This is surely big enough to turn into a GitHub repo of its own by the way. Easier to find there too.

@cvb941
Copy link

cvb941 commented Apr 6, 2024

@cvb941 Thank you for bringing this up! Could you please specify which version of Compose you're referring to?

It is this commit: androidx/androidx@31bc8ad

@jtrollkarl
Copy link

@jtrollkarl I'm not entirely sure I understand your concern about preventing single characters within a string from taking up more than one line. Could you please provide a code snippet or a more detailed explanation? It would help me better grasp the issue and provide a more informed response. Thanks for bringing this up!

@inidamleader
basically when a single word (substring delimited by a whitespace: " ") within a string is very long, i want to prevent that word from taking up more than one line. I want all of its characters on the same line.

@inidamleader
Copy link
Author

@jtrollkarl To ensure that a single long word doesn't wrap onto multiple lines within a string, you have a couple of options:
1- You can set the maxLines parameter to 1. This restricts the text to a single line, preventing any word from wrapping onto a new line.
2- Alternatively, you can use the non-breaking space character in place of regular spaces within your string.

Does this response address your concern adequately? If you have any further questions or if there's anything else I can assist you with, please feel free to let me know.

@inidamleader
Copy link
Author

@hippietrail Hi, If the maxTextSize is not explicitly specified, the text will indeed take up all the available space within its container.

@inidamleader
Copy link
Author

inidamleader commented Apr 7, 2024

@brAzzi64
@cvb941 Thank you for bringing this to my attention. It appears that the TextDelegate function will indeed be internal in Compose in future releases. I'll investigate this further to see if there's a workaround available. However, if Google developers maintain this decision, I'll endeavor to find alternative solutions to address the issue.

This alternative implementation seems like a feasible approach:

val textMeasurer = rememberTextMeasurer()

private fun BoxWithConstraintsScope.shouldShrink2(
    text: AnnotatedString,
    textStyle: TextStyle,
    maxLines: Int,
    layoutDirection: LayoutDirection,
    softWrap: Boolean,
    density: Density,
    fontFamilyResolver: FontFamily.Resolver,
    textMeasurer: TextMeasurer,
) = textMeasurer.measure(
    text = text,
    style = textStyle,
    overflow = TextOverflow.Clip,
    softWrap = softWrap,
    maxLines = maxLines,
    constraints = constraints,
    layoutDirection = layoutDirection,
    density = density,
    fontFamilyResolver = fontFamilyResolver,
).hasVisualOverflow

@jtrollkarl
Copy link

jtrollkarl commented Apr 7, 2024

@inidamleader
I'm still getting line breaks. Here's what my code looks like:

 val textNonBreaking = replaceWithNonBreakingSpace(text)
        AutoSizeText(
            text = textNonBreaking,
            style = style,
            color = color,
            overflow = overflow
        )

private fun replaceWithNonBreakingSpace(input: String): String {
    return input.replace(' ', '\u00A0')
}

@inidamleader
Copy link
Author

inidamleader commented Apr 8, 2024

@jtrollkarl Have you considered using maxLines = 1? Could you also provide a preview to test the entire code? Thanks!
A preview like this one:

@Preview(widthDp = 200, heightDp = 30)
@Composable
fun PreviewAutoSizeTextWithMaxLinesSetToOne() {
    MaterialTheme {
        Surface {
            AutoSizeText(
                text = "This is a bunch of text that will be auto sized",
                modifier = Modifier.fillMaxSize(),
                alignment = Alignment.Center,
                maxLines = 1,
                style = MaterialTheme.typography.bodyMedium
            )
        }
    }
}

@ro0opf
Copy link

ro0opf commented Apr 15, 2024

@ryanholden8 thank you for your help: 1- The small typo has been rectified. 2- Regarding AutoSizeText: its primary function is to utilize the entire available space when maxSize isn't specified. 3- Regarding the IllegalStateException issue, it appears to be unrelated to AutoSizeText.

@inidamleader
I'm having trouble understanding the third point you mentioned. I'm experiencing a similar crash, and when specifying the height as follows: height(IntrinsicSize.Min), how should I implement it?

@ryanholden8
Copy link

@ryanholden8 thank you for your help: 1- The small typo has been rectified. 2- Regarding AutoSizeText: its primary function is to utilize the entire available space when maxSize isn't specified. 3- Regarding the IllegalStateException issue, it appears to be unrelated to AutoSizeText.

@inidamleader I'm having trouble understanding the third point you mentioned. I'm experiencing a similar crash, and when specifying the height as follows: height(IntrinsicSize.Min), how should I implement it?

It probably depends on the UI requirements. For us, we removed height(IntrinsicSize.Min) and used ConstraintLayout instead. Another option could be BoxWithConstraints.

@inidamleader
Copy link
Author

inidamleader commented Apr 15, 2024

@ryanholden8 @ro0opf this is because of undefined constraints (may be infinite constraint height) there is an explanation at the start of this video (60s):
https://youtu.be/Y547UHx5Rc0?si=rRDILNTT6xeLPVEM

You need to make the constraint defined, for example by specifying a defined height or using a height frame that can be calculated by compose.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment