diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/InputTextField.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/InputTextField.kt index c9104337..3ba987cd 100644 --- a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/InputTextField.kt +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/InputTextField.kt @@ -44,23 +44,19 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalTextInputService import androidx.compose.ui.platform.LocalTextToolbar import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.Paragraph import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.createFontFamilyResolver import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.sp import com.sadellie.unitto.core.ui.theme.LocalNumberTypography -import kotlin.math.ceil import kotlin.math.roundToInt @Composable @@ -74,7 +70,7 @@ fun ExpressionTextField( textColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, formatterSymbols: FormatterSymbols, readOnly: Boolean = false, - placeholder: String? = null, + placeholder: String = "", ) { val clipboardManager = LocalClipboardManager.current fun copyCallback() { @@ -113,7 +109,8 @@ fun ExpressionTextField( hideToolbar = textToolbar::hide, visualTransformation = ExpressionTransformer(formatterSymbols), placeholder = placeholder, - textToolbar = textToolbar + textToolbar = textToolbar, + stepGranularityTextSize = 1.sp ) } @@ -127,7 +124,7 @@ fun UnformattedTextField( onCursorChange: (TextRange) -> Unit, textColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, readOnly: Boolean = false, - placeholder: String? = null, + placeholder: String = "", ) { val clipboardManager = LocalClipboardManager.current fun copyCallback() { @@ -168,77 +165,49 @@ fun UnformattedTextField( ) } +// https://gist.github.com/inidamleader/b594d35362ebcf3cedf81055df519300 @Composable -private fun AutoSizableTextField( +fun AutoSizableTextField( modifier: Modifier = Modifier, value: TextFieldValue, formattedValue: String = value.text, textStyle: TextStyle = TextStyle(), - scaleFactor: Float = 0.95f, - minRatio: Float = 1f, onValueChange: (TextFieldValue) -> Unit, readOnly: Boolean = false, showToolbar: (rect: Rect) -> Unit = {}, hideToolbar: () -> Unit = {}, visualTransformation: VisualTransformation = VisualTransformation.None, - placeholder: String? = null, - textToolbar: UnittoTextToolbar + placeholder: String = "", + textToolbar: UnittoTextToolbar, + minRatio: Float = 1f, + stepGranularityTextSize: TextUnit = 1.sp, + suggestedFontSizes: List = emptyList(), ) { + val localDensity = LocalDensity.current + val density = localDensity.density val focusRequester = remember { FocusRequester() } - val density = LocalDensity.current - val textValue = value.copy(value.text.take(2000)) - var nFontSize: TextUnit by remember { mutableStateOf(0.sp) } - var minFontSize: TextUnit + var offset by remember { mutableStateOf(Offset.Zero) } - BoxWithConstraints( - modifier = modifier, - contentAlignment = Alignment.BottomStart + val displayValue = value.copy(text = value.text.take(2000)) + // Change font scale to 1 + CompositionLocalProvider( + LocalDensity provides Density(density = density, fontScale = 1F), + LocalTextInputService provides null, + LocalTextToolbar provides textToolbar ) { - with(density) { - // Cursor handle is not visible without this, 0.836f is the minimum required factor here - nFontSize = maxHeight.toSp() * 0.83f - minFontSize = nFontSize * minRatio - } - - // Modified: https://blog.canopas.com/autosizing-textfield-in-jetpack-compose-7a80f0270853 - val calculateParagraph = @Composable { - Paragraph( - text = formattedValue, - style = textStyle.copy(fontSize = nFontSize), - constraints = Constraints( - maxWidth = ceil(with(density) { maxWidth.toPx() }).toInt() - ), - density = density, - fontFamilyResolver = createFontFamilyResolver(LocalContext.current), - spanStyles = listOf(), - placeholders = listOf(), - maxLines = 1, - ellipsis = false - ) - } - - var intrinsics = calculateParagraph() - with(density) { - while ((intrinsics.maxIntrinsicWidth > maxWidth.toPx()) && nFontSize >= minFontSize) { - nFontSize *= scaleFactor - intrinsics = calculateParagraph() - } - } - - val nTextStyle = textStyle.copy( - // https://issuetracker.google.com/issues/266470454 - // textAlign = TextAlign.End, - fontSize = nFontSize - ) - var offset = Offset.Zero - - CompositionLocalProvider( - LocalTextInputService provides null, - LocalTextToolbar provides textToolbar + BoxWithConstraints( + modifier = modifier, + contentAlignment = Alignment.BottomStart, ) { + val resizeableTextStyle = resizeableTextStyle( + text = formattedValue.ifEmpty { placeholder }, + textStyle = textStyle, + minRatio = minRatio + ) + BasicTextField( - value = textValue, + value = displayValue, onValueChange = { showToolbar(Rect(offset, 0f)) hideToolbar() @@ -256,43 +225,38 @@ private fun AutoSizableTextField( showToolbar(Rect(offset, 0f)) } ) - .widthIn(max = with(density) { intrinsics.width.toDp() }) + .widthIn(max = with(localDensity) { resizeableTextStyle.layoutResult.multiParagraph.width.toDp() }) .layout { measurable, constraints -> val placeable = measurable.measure(constraints) // TextField size is changed with a delay (text jumps). Here we correct it. layout(placeable.width, placeable.height) { placeable.place( - x = (intrinsics.width - intrinsics.maxIntrinsicWidth) + x = (resizeableTextStyle.layoutResult.multiParagraph.width - resizeableTextStyle.layoutResult.multiParagraph.maxIntrinsicWidth) .coerceAtLeast(0f) .roundToInt(), - y = (placeable.height - intrinsics.height).roundToInt() + y = (placeable.height - resizeableTextStyle.layoutResult.multiParagraph.height).roundToInt() ) } } + .onGloballyPositioned { layoutCoords -> offset = layoutCoords.positionInWindow() }, - textStyle = nTextStyle, - cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurfaceVariant), - singleLine = true, readOnly = readOnly, + textStyle = resizeableTextStyle.textStyle, + singleLine = true, visualTransformation = visualTransformation, + onTextLayout = {}, + interactionSource = remember { MutableInteractionSource() }, + cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurfaceVariant), decorationBox = { innerTextField -> - if (textValue.text.isEmpty() and !placeholder.isNullOrEmpty()) { + if (displayValue.text.isEmpty()) { Text( - text = placeholder!!, // It's not null, i swear - style = nTextStyle, + text = placeholder, + style = resizeableTextStyle.textStyle, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), - modifier = Modifier.layout { measurable, constraints -> - val placeable = measurable.measure(constraints) - - layout(placeable.width, placeable.height) { - placeable.place(x = -placeable.width, y = 0) - } - } ) } - innerTextField() } ) @@ -320,7 +284,7 @@ fun ClipboardManager.copyWithoutGrouping( ) ) -fun ClipboardManager.copy(value: TextFieldValue) = this.setText( +private fun ClipboardManager.copy(value: TextFieldValue) = this.setText( AnnotatedString( value.annotatedString .subSequence(value.selection) diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/Resizeable.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/Resizeable.kt new file mode 100644 index 00000000..96f8a17e --- /dev/null +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/Resizeable.kt @@ -0,0 +1,174 @@ +/* + * Unitto is a unit converter for Android + * Copyright (c) 2023 Elshan Agaev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.sadellie.unitto.core.ui.common.textfield + +import androidx.compose.foundation.layout.BoxWithConstraintsScope +import androidx.compose.foundation.text.InternalFoundationTextApi +import androidx.compose.foundation.text.TextDelegate +import androidx.compose.material3.LocalTextStyle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.TextStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.isUnspecified +import androidx.compose.ui.unit.min +import androidx.compose.ui.unit.sp +import kotlin.math.ceil +import kotlin.math.floor +import kotlin.reflect.KProperty + +@Composable +internal fun BoxWithConstraintsScope.resizeableTextStyle( + text: String, + textStyle: TextStyle, + minRatio: Float, + stepGranularityTextSize: TextUnit = 1.sp, + suggestedFontSizes: List = emptyList(), +): ResizeableTextStyle { + val fontSizes = suggestedFontSizes.toImmutableWrapper() + val localDensity = LocalDensity.current + val maxTextSize by remember { + with(localDensity) { + mutableStateOf(maxHeight.toSp()) + } + } + + val minTextSize by remember { + mutableStateOf(maxTextSize * minRatio) + } + + // (1 / density).sp represents 1px when font scale equals 1 + val step = remember(stepGranularityTextSize) { + (1 / localDensity.density).let { + if (stepGranularityTextSize.isUnspecified) + it.sp + else + stepGranularityTextSize.value.coerceAtLeast(it).sp + } + } + + val max = remember(maxWidth, maxHeight, maxTextSize) { + min(maxWidth, maxHeight).value.let { + if (maxTextSize.isUnspecified) + it.sp + else + maxTextSize.value.coerceAtMost(it).sp + } + } + + val min = remember(minTextSize, step, max) { + if (minTextSize.isUnspecified) + step + else + minTextSize.value.coerceIn( + minimumValue = step.value, + maximumValue = max.value + ).sp + } + + val possibleFontSizes = remember(fontSizes, min, max, step) { + if (fontSizes.value.isEmpty()) { + val firstIndex = ceil(min.value / step.value).toInt() + val lastIndex = floor(max.value / step.value).toInt() + MutableList(size = (lastIndex - firstIndex) + 1) { index -> + step * (lastIndex - index) + } + } else + fontSizes.value.filter { + it.isSp && it.value in min.value..max.value + }.sortedByDescending { + it.value + } + } + + var combinedTextStyle = LocalTextStyle.current + textStyle + var layoutResult: TextLayoutResult = layoutText( + text = text, + textStyle = combinedTextStyle, + maxLines = 1, + softWrap = false, + ) + + if (possibleFontSizes.isNotEmpty()) { + // Dichotomous binary search + var low = 0 + var high = possibleFontSizes.lastIndex + while (low <= high) { + val mid = low + (high - low) / 2 + layoutResult = layoutText( + text = text, + textStyle = combinedTextStyle.copy(fontSize = possibleFontSizes[mid]), + maxLines = 1, + softWrap = false, + ) + + if (layoutResult.hasVisualOverflow) low = mid + 1 + else high = mid - 1 + } + combinedTextStyle = combinedTextStyle.copy( + fontSize = possibleFontSizes[low.coerceIn(possibleFontSizes.indices)], + ) + } + + return ResizeableTextStyle( + combinedTextStyle, layoutResult + ) +} + +@OptIn(InternalFoundationTextApi::class) +@Composable +internal fun BoxWithConstraintsScope.layoutText( + text: String, + textStyle: TextStyle, + maxLines: Int, + softWrap: Boolean, +): TextLayoutResult = TextDelegate( + text = AnnotatedString(text), + style = textStyle, + maxLines = maxLines, + softWrap = softWrap, + overflow = TextOverflow.Clip, + density = LocalDensity.current, + fontFamilyResolver = LocalFontFamilyResolver.current, +) + .layout(constraints, LocalLayoutDirection.current) + +@Immutable +internal data class ResizeableTextStyle( + val textStyle: TextStyle, + val layoutResult: TextLayoutResult, +) + +@Immutable +private data class ImmutableWrapper( + val value: T, +) + +private fun T.toImmutableWrapper() = ImmutableWrapper(this) + +private operator fun ImmutableWrapper.getValue(thisRef: Any?, property: KProperty<*>) = value diff --git a/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/components/TextBox.kt b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/components/TextBox.kt index 14f68fba..c6e927ba 100644 --- a/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/components/TextBox.kt +++ b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/components/TextBox.kt @@ -111,7 +111,7 @@ fun TextBox( .fillMaxWidth() .padding(horizontal = 8.dp), value = TextFieldValue(label), - minRatio = 1f, + minRatio = 0.8f, onCursorChange = {}, textColor = MaterialTheme.colorScheme.error, readOnly = true, diff --git a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterScreen.kt b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterScreen.kt index de4709fd..a5252bdf 100644 --- a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterScreen.kt +++ b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterScreen.kt @@ -288,7 +288,7 @@ private fun Default( modifier = modifier.fillMaxSize(), content1 = { contentModifier -> ColumnWithConstraints(modifier = contentModifier) { - val textFieldModifier = Modifier.weight(2f) + val textFieldModifier = Modifier.fillMaxWidth().weight(2f) AnimatedVisibility( visible = lastUpdate != null,