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.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<TextUnit> = 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)

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()
.padding(horizontal = 8.dp),
value = TextFieldValue(label),
minRatio = 1f,
minRatio = 0.8f,
onCursorChange = {},
textColor = MaterialTheme.colorScheme.error,
readOnly = true,

View File

@ -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,