-
-
Save inidamleader/b594d35362ebcf3cedf81055df519300 to your computer and use it in GitHub Desktop.
// 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 |
There are some unused functions in DensityExt.kt file, don't forget to remove them
@inidamleader lineSpacingRatio
might be NaN too, it could be fixed with this:
val coercedLineSpacingRatio = lineSpacingRatio.coerceAtLeast(1f).takeIf { !lineSpacingRatio.isNaN() } ?: 1f
@sergibc Yes, good idea, thank you. Update done
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.
Sadly Can't use it in Lazy Layouts
@ahmedhosnypro It should work, can you share a code snippet?
Works like a charm. Thanks a lot!
Small Typo on line 62, empty
instead of ampty
- https://gist.github.com/inidamleader/b594d35362ebcf3cedf81055df519300#file-autosizetext-kt-L62
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,
) {
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",
)
}
}
@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
Is there any way to prevent single characters, within a string, from taking up more than 1 line?
The TextDelegate
class seems to be internal now in Compose...
@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!
@cvb941 Thank you for bringing this up! Could you please specify which version of Compose you're referring to?
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."
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 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 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.
@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.
@hippietrail Hi, If the maxTextSize is not explicitly specified, the text will indeed take up all the available space within its container.
@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
@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')
}
@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
)
}
}
}
@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 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.
@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.
@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)