Refactor InputTextField

This commit is contained in:
Sad Ellie 2023-10-12 11:06:38 +03:00
parent 337a68b623
commit aa2f66f891
4 changed files with 220 additions and 82 deletions

View File

@ -44,23 +44,19 @@ import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalTextInputService import androidx.compose.ui.platform.LocalTextInputService
import androidx.compose.ui.platform.LocalTextToolbar import androidx.compose.ui.platform.LocalTextToolbar
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.Paragraph
import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle 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.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation 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.TextUnit
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.sadellie.unitto.core.ui.theme.LocalNumberTypography import com.sadellie.unitto.core.ui.theme.LocalNumberTypography
import kotlin.math.ceil
import kotlin.math.roundToInt import kotlin.math.roundToInt
@Composable @Composable
@ -74,7 +70,7 @@ fun ExpressionTextField(
textColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, textColor: Color = MaterialTheme.colorScheme.onSurfaceVariant,
formatterSymbols: FormatterSymbols, formatterSymbols: FormatterSymbols,
readOnly: Boolean = false, readOnly: Boolean = false,
placeholder: String? = null, placeholder: String = "",
) { ) {
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
fun copyCallback() { fun copyCallback() {
@ -113,7 +109,8 @@ fun ExpressionTextField(
hideToolbar = textToolbar::hide, hideToolbar = textToolbar::hide,
visualTransformation = ExpressionTransformer(formatterSymbols), visualTransformation = ExpressionTransformer(formatterSymbols),
placeholder = placeholder, placeholder = placeholder,
textToolbar = textToolbar textToolbar = textToolbar,
stepGranularityTextSize = 1.sp
) )
} }
@ -127,7 +124,7 @@ fun UnformattedTextField(
onCursorChange: (TextRange) -> Unit, onCursorChange: (TextRange) -> Unit,
textColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, textColor: Color = MaterialTheme.colorScheme.onSurfaceVariant,
readOnly: Boolean = false, readOnly: Boolean = false,
placeholder: String? = null, placeholder: String = "",
) { ) {
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
fun copyCallback() { fun copyCallback() {
@ -168,77 +165,49 @@ fun UnformattedTextField(
) )
} }
// https://gist.github.com/inidamleader/b594d35362ebcf3cedf81055df519300
@Composable @Composable
private fun AutoSizableTextField( fun AutoSizableTextField(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
value: TextFieldValue, value: TextFieldValue,
formattedValue: String = value.text, formattedValue: String = value.text,
textStyle: TextStyle = TextStyle(), textStyle: TextStyle = TextStyle(),
scaleFactor: Float = 0.95f,
minRatio: Float = 1f,
onValueChange: (TextFieldValue) -> Unit, onValueChange: (TextFieldValue) -> Unit,
readOnly: Boolean = false, readOnly: Boolean = false,
showToolbar: (rect: Rect) -> Unit = {}, showToolbar: (rect: Rect) -> Unit = {},
hideToolbar: () -> Unit = {}, hideToolbar: () -> Unit = {},
visualTransformation: VisualTransformation = VisualTransformation.None, visualTransformation: VisualTransformation = VisualTransformation.None,
placeholder: String? = null, placeholder: String = "",
textToolbar: UnittoTextToolbar textToolbar: UnittoTextToolbar,
minRatio: Float = 1f,
stepGranularityTextSize: TextUnit = 1.sp,
suggestedFontSizes: List<TextUnit> = emptyList(),
) { ) {
val localDensity = LocalDensity.current
val density = localDensity.density
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val density = LocalDensity.current
val textValue = value.copy(value.text.take(2000)) var offset by remember { mutableStateOf(Offset.Zero) }
var nFontSize: TextUnit by remember { mutableStateOf(0.sp) }
var minFontSize: TextUnit
BoxWithConstraints(
modifier = modifier,
contentAlignment = Alignment.BottomStart
) {
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
val displayValue = value.copy(text = value.text.take(2000))
// Change font scale to 1
CompositionLocalProvider( CompositionLocalProvider(
LocalDensity provides Density(density = density, fontScale = 1F),
LocalTextInputService provides null, LocalTextInputService provides null,
LocalTextToolbar provides textToolbar LocalTextToolbar provides textToolbar
) { ) {
BoxWithConstraints(
modifier = modifier,
contentAlignment = Alignment.BottomStart,
) {
val resizeableTextStyle = resizeableTextStyle(
text = formattedValue.ifEmpty { placeholder },
textStyle = textStyle,
minRatio = minRatio
)
BasicTextField( BasicTextField(
value = textValue, value = displayValue,
onValueChange = { onValueChange = {
showToolbar(Rect(offset, 0f)) showToolbar(Rect(offset, 0f))
hideToolbar() hideToolbar()
@ -256,43 +225,38 @@ private fun AutoSizableTextField(
showToolbar(Rect(offset, 0f)) showToolbar(Rect(offset, 0f))
} }
) )
.widthIn(max = with(density) { intrinsics.width.toDp() }) .widthIn(max = with(localDensity) { resizeableTextStyle.layoutResult.multiParagraph.width.toDp() })
.layout { measurable, constraints -> .layout { measurable, constraints ->
val placeable = measurable.measure(constraints) val placeable = measurable.measure(constraints)
// TextField size is changed with a delay (text jumps). Here we correct it. // TextField size is changed with a delay (text jumps). Here we correct it.
layout(placeable.width, placeable.height) { layout(placeable.width, placeable.height) {
placeable.place( placeable.place(
x = (intrinsics.width - intrinsics.maxIntrinsicWidth) x = (resizeableTextStyle.layoutResult.multiParagraph.width - resizeableTextStyle.layoutResult.multiParagraph.maxIntrinsicWidth)
.coerceAtLeast(0f) .coerceAtLeast(0f)
.roundToInt(), .roundToInt(),
y = (placeable.height - intrinsics.height).roundToInt() y = (placeable.height - resizeableTextStyle.layoutResult.multiParagraph.height).roundToInt()
) )
} }
} }
.onGloballyPositioned { layoutCoords -> .onGloballyPositioned { layoutCoords ->
offset = layoutCoords.positionInWindow() offset = layoutCoords.positionInWindow()
}, },
textStyle = nTextStyle,
cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurfaceVariant),
singleLine = true,
readOnly = readOnly, readOnly = readOnly,
textStyle = resizeableTextStyle.textStyle,
singleLine = true,
visualTransformation = visualTransformation, visualTransformation = visualTransformation,
onTextLayout = {},
interactionSource = remember { MutableInteractionSource() },
cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurfaceVariant),
decorationBox = { innerTextField -> decorationBox = { innerTextField ->
if (textValue.text.isEmpty() and !placeholder.isNullOrEmpty()) { if (displayValue.text.isEmpty()) {
Text( Text(
text = placeholder!!, // It's not null, i swear text = placeholder,
style = nTextStyle, style = resizeableTextStyle.textStyle,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), 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() innerTextField()
} }
) )
@ -320,7 +284,7 @@ fun ClipboardManager.copyWithoutGrouping(
) )
) )
fun ClipboardManager.copy(value: TextFieldValue) = this.setText( private fun ClipboardManager.copy(value: TextFieldValue) = this.setText(
AnnotatedString( AnnotatedString(
value.annotatedString value.annotatedString
.subSequence(value.selection) .subSequence(value.selection)

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<TextUnit> = 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<T>(
val value: T,
)
private fun <T> T.toImmutableWrapper() = ImmutableWrapper(this)
private operator fun <T> ImmutableWrapper<T>.getValue(thisRef: Any?, property: KProperty<*>) = value

View File

@ -111,7 +111,7 @@ fun TextBox(
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp), .padding(horizontal = 8.dp),
value = TextFieldValue(label), value = TextFieldValue(label),
minRatio = 1f, minRatio = 0.8f,
onCursorChange = {}, onCursorChange = {},
textColor = MaterialTheme.colorScheme.error, textColor = MaterialTheme.colorScheme.error,
readOnly = true, readOnly = true,

View File

@ -288,7 +288,7 @@ private fun Default(
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
content1 = { contentModifier -> content1 = { contentModifier ->
ColumnWithConstraints(modifier = contentModifier) { ColumnWithConstraints(modifier = contentModifier) {
val textFieldModifier = Modifier.weight(2f) val textFieldModifier = Modifier.fillMaxWidth().weight(2f)
AnimatedVisibility( AnimatedVisibility(
visible = lastUpdate != null, visible = lastUpdate != null,