Skip to content

Instantly share code, notes, and snippets.

@inidamleader
Last active July 18, 2024 14:35
Show Gist options
  • 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
/*
MIT License
Copyright (c) 2024 Reda El Madini
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
// LAST UPDATE: 03 Jul 2024 V6.2
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.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.Stable
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.rememberTextMeasurer
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.validSuggestedFontSizes
import com.inidamleader.ovtracker.util.compose.geometry.dpSizeRoundToIntSize
import com.inidamleader.ovtracker.util.compose.geometry.intPxToSp
import com.inidamleader.ovtracker.util.compose.geometry.spRoundToPx
import com.inidamleader.ovtracker.util.compose.geometry.spToIntPx
import kotlin.math.min
private const val TAG = "AutoSizeText"
/**
* 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 lineSpaceRatio 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: List<TextUnit> = emptyList(),
suggestedFontSizesStatus: SuggestedFontSizesStatus = SuggestedFontSizesStatus.UNKNOWN,
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,
lineSpaceRatio: 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 = lineSpaceRatio,
)
}
/**
* 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: List<TextUnit> = emptyList(),
suggestedFontSizesStatus: SuggestedFontSizesStatus = SuggestedFontSizesStatus.UNKNOWN,
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: Map<String, InlineTextContent> = mapOf(),
onTextLayout: (TextLayoutResult) -> Unit = {},
style: TextStyle = LocalTextStyle.current,
lineSpacingRatio: Float = style.lineHeight.value / style.fontSize.value,
) {
// Change font scale to 1F
val newDensity = Density(density = LocalDensity.current.density, fontScale = 1F)
CompositionLocalProvider(LocalDensity provides newDensity) {
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 textMeasurer = rememberTextMeasurer()
val coercedLineSpacingRatio = lineSpacingRatio.takeIf { it.isFinite() && it >= 1 } ?: 1F
val shouldMoveBackward: (TextUnit) -> Boolean = {
shouldShrink(
text = text,
textStyle = combinedTextStyle.copy(
fontSize = it,
lineHeight = it * coercedLineSpacingRatio,
),
maxLines = maxLines,
layoutDirection = layoutDirection,
softWrap = softWrap,
density = density,
fontFamilyResolver = fontFamilyResolver,
textMeasurer = textMeasurer,
)
}
val electedFontSize = remember(key1 = suggestedFontSizes) {
if (suggestedFontSizesStatus == SuggestedFontSizesStatus.VALID)
suggestedFontSizes
else
suggestedFontSizes.validSuggestedFontSizes
}?.let {
remember(
key1 = it,
key2 = shouldMoveBackward,
) {
it.findElectedValue(shouldMoveBackward = shouldMoveBackward)
}
} ?: run {
val candidateFontSizesIntProgress = rememberCandidateFontSizesIntProgress(
density = density,
containerDpSize = DpSize(maxWidth, maxHeight),
maxTextSize = maxTextSize,
minTextSize = minTextSize,
stepGranularityTextSize = stepGranularityTextSize,
)
remember(
key1 = candidateFontSizesIntProgress,
key2 = shouldMoveBackward,
) {
candidateFontSizesIntProgress.findElectedValue(
transform = { density.intPxToSp(it) },
shouldMoveBackward = shouldMoveBackward,
)
}
}
if (electedFontSize == 0.sp)
Log.w(
TAG,
"""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,
onTextLayout = onTextLayout,
style = combinedTextStyle.copy(
fontSize = electedFontSize,
lineHeight = electedFontSize * coercedLineSpacingRatio,
),
)
}
}
}
private fun BoxWithConstraintsScope.shouldShrink(
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
@Stable
@Composable
private fun rememberCandidateFontSizesIntProgress(
density: Density,
containerDpSize: DpSize,
minTextSize: TextUnit = TextUnit.Unspecified,
maxTextSize: TextUnit = TextUnit.Unspecified,
stepGranularityTextSize: TextUnit = TextUnit.Unspecified,
): IntProgression {
val max = remember(key1 = density, key2 = maxTextSize, key3 = containerDpSize) {
val intSize = density.dpSizeRoundToIntSize(containerDpSize)
min(intSize.width, intSize.height).let { max ->
maxTextSize
.takeIf { it.isSp }
?.let { density.spRoundToPx(it) }
?.coerceIn(range = 0..max)
?: max
}
}
val min = remember(key1 = density, key2 = minTextSize, key3 = max) {
minTextSize
.takeIf { it.isSp }
?.let { density.spToIntPx(it) }
?.coerceIn(range = 0..max)
?: 0
}
val step = remember(
key1 = listOf(
density,
min,
max,
stepGranularityTextSize,
)
) {
stepGranularityTextSize
.takeIf { it.isSp }
?.let { density.spToIntPx(it) }
?.coerceIn(1, 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 <T> List<T>.findElectedValue(shouldMoveBackward: (T) -> Boolean) = run {
indices.findElectedValue(
transform = { this[it] },
shouldMoveBackward = shouldMoveBackward,
)
}
// This function works by using a binary search algorithm
private fun <T> IntProgression.findElectedValue(
transform: (Int) -> T,
shouldMoveBackward: (T) -> 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(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 List<TextUnit>.validSuggestedFontSizes
get() = takeIf { it.isNotEmpty() } // Optimization: empty check first to immediately return null
?.filter { it.isSp }
?.takeIf { it.isNotEmpty() }
?.sortedBy { it.value }
}
}
@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,
lineSpaceRatio = 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, 8.sp).suggestedFontSizesStatus == SuggestedFontSizesStatus.VALID
}
assertTrue {
listOf(2.sp, 8.sp, 1.sp, 4.sp).suggestedFontSizesStatus == SuggestedFontSizesStatus.INVALID
}
assertTrue {
emptyList<TextUnit>().suggestedFontSizesStatus == SuggestedFontSizesStatus.INVALID
}
assertTrue {
listOf(1.sp, 2.em, 8.sp, 4.sp).suggestedFontSizesStatus == SuggestedFontSizesStatus.INVALID
}
}
}
package com.inidamleader.ovtracker.util.compose.geometry
import androidx.compose.runtime.Composable
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.geometry.isSpecified
import androidx.compose.ui.platform.LocalDensity
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.dp
import androidx.compose.ui.unit.isSpecified
import kotlin.math.roundToInt
// Those function are designed to be used in lambdas
// DP
fun Density.dpToSp(dp: Dp) = if (dp.isSpecified) dp.toSp() else TextUnit.Unspecified
fun Density.dpToFloatPx(dp: Dp) = if (dp.isSpecified) dp.toPx() else Float.NaN
fun Density.dpToIntPx(dp: Dp) = if (dp.isSpecified) dp.toPx().toInt() else 0
fun Density.dpRoundToPx(dp: Dp) = if (dp.isSpecified) dp.roundToPx() else 0
@Composable
fun Dp.toSp() = LocalDensity.current.dpToSp(this)
@Composable
fun Dp.toFloatPx() = LocalDensity.current.dpToFloatPx(this)
@Composable
fun Dp.toIntPx() = LocalDensity.current.dpToIntPx(this)
@Composable
fun Dp.roundToPx() = LocalDensity.current.dpRoundToPx(this)
fun Dp.toRecDpSize() = if (isSpecified) DpSize(this, this) else DpSize.Unspecified
fun Dp.toRecDpOffset() = if (isSpecified) DpOffset(this, this) else DpOffset.Unspecified
// TEXT UNIT
fun Density.spToDp(sp: TextUnit) = if (sp.isSpecified) sp.toDp() else Dp.Unspecified
fun Density.spToFloatPx(sp: TextUnit) = if (sp.isSpecified) sp.toPx() else Float.NaN
fun Density.spToIntPx(sp: TextUnit) = if (sp.isSpecified) sp.toPx().toInt() else 0
fun Density.spRoundToPx(sp: TextUnit) = if (sp.isSpecified) sp.roundToPx() else 0
@Composable
fun TextUnit.toDp() = LocalDensity.current.spToDp(this)
@Composable
fun TextUnit.toFloatPx() = LocalDensity.current.spToFloatPx(this)
@Composable
fun TextUnit.toIntPx() = LocalDensity.current.spToIntPx(this)
@Composable
fun TextUnit.roundToPx() = LocalDensity.current.spRoundToPx(this)
// FLOAT
fun Density.floatPxToDp(px: Float) = if (px.isFinite()) px.toDp() else Dp.Unspecified
fun Density.floatPxToSp(px: Float) = if (px.isFinite()) px.toSp() else TextUnit.Unspecified
@Composable
fun Float.toDp() = LocalDensity.current.floatPxToDp(this)
@Composable
fun Float.toSp() = LocalDensity.current.floatPxToSp(this)
fun Float.toIntPx() = if (isFinite()) toInt() else 0
fun Float.roundToPx() = if (isFinite()) roundToInt() else 0
fun Float.toRecSize() = if (isFinite()) Size(this, this) else Size.Unspecified
fun Float.toRecOffset() = if (isFinite()) Offset(this, this) else Offset.Unspecified
// INT
fun Density.intPxToDp(px: Int) = px.toDp()
fun Density.intPxToSp(px: Int) = px.toSp()
@Composable
fun Int.toDp() = LocalDensity.current.intPxToDp(this)
@Composable
fun Int.toSp() = LocalDensity.current.intPxToSp(this)
fun Int.toFloatPx() = toFloat()
fun Int.toRecIntSize() = IntSize(this, this)
fun Int.toRecIntOffset() = IntOffset(this, this)
// DP SIZE
fun Density.dpSizeToIntSize(dpSize: DpSize) =
if (dpSize.isSpecified) IntSize(dpSize.width.toPx().toInt(), dpSize.height.toPx().toInt())
else IntSize.Zero
fun Density.dpSizeRoundToIntSize(dpSize: DpSize) =
if (dpSize.isSpecified) IntSize(dpSize.width.roundToPx(), dpSize.height.roundToPx())
else IntSize.Zero
fun Density.dpSizeToSize(dpSize: DpSize) =
if (dpSize.isSpecified) Size(dpSize.width.toPx(), dpSize.height.toPx())
else Size.Unspecified
@Composable
fun DpSize.toIntSize() = LocalDensity.current.dpSizeToIntSize(this)
@Composable
fun DpSize.roundToIntSize() = LocalDensity.current.dpSizeRoundToIntSize(this)
@Composable
fun DpSize.toSize() = LocalDensity.current.dpSizeToSize(this)
fun DpSize.isSpaced() = isSpecified && width > 0.dp && height > 0.dp
// SIZE
fun Density.sizeToDpSize(size: Size) =
if (size.isSpecified) DpSize(size.width.toDp(), size.height.toDp())
else DpSize.Unspecified
@Composable
fun Size.toDpSize() =
if (isSpecified) LocalDensity.current.sizeToDpSize(this)
else DpSize.Unspecified
fun Size.toIntSize() =
if (isSpecified) IntSize(width.toInt(), height.toInt())
else IntSize.Zero
fun Size.isSpaced() = isSpecified && width > 0F && height > 0F
// INT SIZE
fun Density.intSizeToDpSize(intSize: IntSize) = DpSize(intSize.width.toDp(), intSize.height.toDp())
@Composable
fun IntSize.toDpSize() = LocalDensity.current.intSizeToDpSize(this)
@Composable
fun IntSize.toSize() = Size(width.toFloat(), height.toFloat())
fun IntSize.isSpaced() = width > 0 && height > 0
// DP OFFSET
fun Density.dpOffsetToIntOffset(dpOffset: DpOffset) =
if (dpOffset.isSpecified) IntOffset(dpOffset.x.toPx().toInt(), dpOffset.y.toPx().toInt())
else IntOffset.Zero
fun Density.dpOffsetRoundToIntOffset(dpOffset: DpOffset) =
if (dpOffset.isSpecified) IntOffset(dpOffset.x.roundToPx(), dpOffset.y.roundToPx())
else IntOffset.Zero
fun Density.dpOffsetToOffset(dpOffset: DpOffset) =
if (dpOffset.isSpecified) Offset(dpOffset.x.toPx(), dpOffset.y.toPx())
else Offset.Unspecified
@Composable
fun DpOffset.toIntOffset() = LocalDensity.current.dpOffsetToIntOffset(this)
@Composable
fun DpOffset.roundToIntOffset() = LocalDensity.current.dpOffsetRoundToIntOffset(this)
@Composable
fun DpOffset.toOffset() = LocalDensity.current.dpOffsetToOffset(this)
// OFFSET
fun Density.offsetToDpOffset(offset: Offset) =
if (offset.isSpecified) DpOffset(offset.x.toDp(), offset.y.toDp())
else DpOffset.Unspecified
@Composable
fun Offset.toDpOffset() = LocalDensity.current.offsetToDpOffset(this)
fun Offset.toIntOffset() =
if (isSpecified) IntOffset(x.toInt(), y.toInt())
else IntOffset.Zero
// INT OFFSET
fun Density.intOffsetToDpOffset(intOffset: IntOffset) = DpOffset(intOffset.x.toDp(), intOffset.y.toDp())
@Composable
fun IntOffset.toDpOffset() = LocalDensity.current.intOffsetToDpOffset(this)
fun IntOffset.toOffset() = Offset(x.toFloat(), y.toFloat())
@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.

@lkjh654
Copy link

lkjh654 commented Jul 4, 2024

@inidamleader Could you please provide a license for the gist :)? Preferably a permissive one like "MIT License".

Anyways it would be great if the gist was converted to a standalone library.

@inidamleader
Copy link
Author

inidamleader commented Jul 4, 2024

Hi @lkjh654

Sure! I’ve added the MIT License to the gist.

Regarding converting the gist into a standalone library, I believe keeping it as a gist provides developers with more flexibility to adapt the code to their specific needs. Additionally, some developers might find adding a new library to their project daunting. Keeping it as a gist allows for easier integration and customization.

Thanks for your feedback!

@NelzkieLabs
Copy link

Works wonderfully. Thank you for this

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