-
-
Save inidamleader/b594d35362ebcf3cedf81055df519300 to your computer and use it in GitHub Desktop.
/* | |
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. | |
*/ | |
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 androidx.compose.ui.util.fastAll | |
import androidx.compose.ui.util.fastFilter | |
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 [email protected] | |
*/ | |
@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, | |
key2 = suggestedFontSizesStatus, | |
) { | |
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() && fastAll { 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 | |
?.fastFilter { 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()) |
@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.
@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.
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!
Works wonderfully. Thank you for this
Hey @inidamleader !
I came from this Stackoverflow question and hesitated to use your Gist first in the first place. For it is a lot of code and there are simpler solutions.
But your component solved some problems that i did not even think about. So thanks for this and for keeping it up to date! 👍
Just 2 questions/suggestions:
density.kt
: After cleaning up a bit, i found that only 4 of the functions are actually used. Couldn't they also be moved toAutoSizeText.kt
and be private?- The preview functions could be private, right?
@r-dent Thanks for the feedback!
Yes, the preview functions can be made private, and you can delete the unused functions in the density.kt file. Feel free to modify the code as needed to fit your requirements.
I appreciate your suggestions. Thanks again! However, I will keep the code as it is for now because it may help in the future.
Hi @inidamleader, I really like your implementation and it works perfectly for me!
If you do not object, i would like to use it in my Qalculate! application, which is licensed under GPL 2.
This is splendid! Great work!
Hello,
Just an FYI: the run block that begins on line 263 shows this message in Android Studio:
Elvis operator (?:) always returns the left operand of non-nullable type TextUnit
val text = buildAnnotatedString {
withStyle(style = SpanStyle(color = Color.Black, fontSize = 12.sp)){
append("dasdasdasdas")
}
withStyle(style = SpanStyle(color = Color.Black, fontSize = 12.sp)){
append("dasdasdasdas")
}
withStyle(style = SpanStyle(color = Color.Black, fontSize = 12.sp)){
append("dasdasdasdas")
}
withStyle(style = SpanStyle(color = Color.Black, fontSize = 12.sp)){
append("dasdasdasdas")
}
}
AutoSizeText(
text = text,
modifier = Modifier.fillMaxWidth(),
minTextSize = 5.sp,
maxTextSize = 12.sp,
maxLines = 1,
alignment = Alignment.CenterStart,
style = MaterialTheme.typography.bodyMedium,
)
@leeManong I played around with your code, and I can confirm there is an issue with AnnotatedString when setting maxLines to 1. To work around this, you can either remove the maxLines argument or use TextOverflow.Ellipsis to handle the text truncation gracefully.
How do I autosize based on height only? E.g. I have a box 10 by 1 and text of 1000 chars. I want only 10 first chars displayed in one line and the rest clipped.
I tried setting maxLines = 1 and overflow = TextOverflow.Clip, but these alone didn't change anything.
Great gist btw, thank you
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
Reproducible Code Everytime