4

I'm trying to give some format to a TextField element for currency. I don't want to accept $ or € symbols at the beginning. I just want to accept the following range of values.

MIN VALUE: X
MAX VALUE: XXX.XX

I want to accept a . for split the integers from decimal.

For example minimum an integer and maximum three integer ( 0 — 999)

And for decimal part accept none of maximum two digits ( 0.99 — 0.1 — 1)

For the validations, I just want to have a leading zero just if it comes with decimal numbers, but if I put an integer the leading zero should be deleted. For example:

Can't accept these values:

00.2
0123.30

How can I do this with Jetpack Compose on Android?

cblnpa
  • 397
  • 1
  • 4
  • 20
  • see this [link](https://stackoverflow.com/questions/69064137/how-to-create-a-text-field-input-with-mask-in-jetpack-compose) – Mahmoud Jun 21 '22 at 04:28

1 Answers1

1

First, the composable for a text field is androidx.compose.material.TextField. Given that only numbers will be allowed, choose the Number Keyboard Type with:

 keyboardOptions = KeyboardOptions.Default.copy(
                keyboardType = KeyboardType.Number,
            ),

You can restrict the wrong inputs in onValueChange function closure:

var textState by remember { mutableStateOf("") }
.....
onValueChange = { newText: String ->
            if (newText.isDigitsOnly() &&
 conditionMinValue(newText) &&
 conditionMaxValue(newText) && etc...) {
                textState = newText
            }
        },

Although, the changing of text in onValueChanged callback for the visual part is not recommended because the text state is shared with out process IME (software keyboard) and then you would have to handle errors with the keyboard.

You need to implement your own Visual transformation class for the visual part

visualTransformation = NumberCommaTransformation(), 

You can read: https://blog.shreyaspatil.dev/filtering-and-modifying-text-input-in-jetpack-compose-way

and: How to format number with comma in TextField in compose

See you.

UPDATE

I delete the first Update and I left the last code

UPDATE 2nd

My code IS:

package com.intecanar.candida.ui.newbrand.product.components

import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import java.text.DecimalFormat
import kotlin.math.max

fun priceValueToStringFloat(price: Float): String {
    //from float to "floatString"
    return price.toString()
        .replace(".", "")
        .trimStart('0')
}

fun myAmountInputToFloat(amount: String): Float {
    val price = 0.0f
    if (amount.isEmpty()) {
        return price
    }
    val symbols = DecimalFormat().decimalFormatSymbols
    val decimalSeparator = symbols.decimalSeparator

    val integerPart: String = if (amount.length > 2) {
        amount.subSequence(0, amount.length - 2).toString()
    } else {
        "0"
    }
    var fractionPart: String = if (amount.length >= 2) {
        amount.subSequence(amount.length - 2, amount.length).toString()
    } else {
        amount
    }
    // Add zeros if the fraction part length is not 2
    if (fractionPart.length < 2) {
        fractionPart = fractionPart.padStart(2, '0')
    }
    return (
            integerPart + decimalSeparator + fractionPart
            ).toFloat()
}

class NumberCommaTransformation(val showCurrency: Boolean = false) : VisualTransformation {
    override fun filter(text: AnnotatedString): TransformedText {

        val (integerPart, newAnnotatedText) = formatText(text, showCurrency)

        val offsetMapping = ThousandSeparatorOffsetMapping(
            originalIntegerLength = integerPart.length,
            showCurrency = showCurrency,
            originalTextLength = text.length
        )

        return TransformedText(
            text = newAnnotatedText,
            offsetMapping = offsetMapping
        )
    }

    private fun formatText(
        text: AnnotatedString,
        showCurrency: Boolean
    ): Pair<CharSequence, AnnotatedString> {

        val symbols = DecimalFormat().decimalFormatSymbols
        val thousandsSeparator = symbols.groupingSeparator
        val decimalSeparator = symbols.decimalSeparator
        val currencySymbol = symbols.currencySymbol

        val originalText: String = text.text

        val integerPart = if (originalText.length > 2) {
            originalText.subSequence(0, originalText.length - 2)
        } else {
            "0"
        }
        var fractionPart = if (originalText.length >= 2) {
            originalText.subSequence(originalText.length - 2, originalText.length)
        } else {
            originalText
        }
        // Add zeros if the fraction part length is not 2
        if (fractionPart.length < 2) {
            fractionPart = fractionPart.padStart(2, '0')
        }

        val thousandsReplacementPattern = Regex("\\B(?=(?:\\d{3})+(?!\\d))")
        val formattedIntWithThousandsSeparator =
            integerPart.replace(
                thousandsReplacementPattern,
                thousandsSeparator.toString()
            )


        val formattedText = if (showCurrency) {
            currencySymbol + " " +
                    formattedIntWithThousandsSeparator + decimalSeparator + fractionPart
        } else {
            formattedIntWithThousandsSeparator + decimalSeparator + fractionPart
        }

        val newAnnotatedText = AnnotatedString(
            formattedText,
            text.spanStyles,
            text.paragraphStyles
        )
        return Pair(integerPart, newAnnotatedText)
    }
}

class ThousandSeparatorOffsetMapping(
    val originalIntegerLength: Int, val showCurrency: Boolean, val originalTextLength: Int
) : OffsetMapping {
    private val currencyOffset: Int = if (showCurrency) 2 else 0 //currencySymbol + " "
    private val defaultValueOffset: Int = 4 // "0.00"
    private val decimalSeparatorOffset: Int = 1 // "."
    //counting the extra characters shows after transformation
    /**
     * It is important to understand that we prefer for our cursor to remain stationary at
     * the end of the sum. So, if the input is empty, or we just inserted two digits,
     * our output string will always have minimum 4 characters (“0.00”),
     * that’s why we fixed originalToTransformed offset 0, 1, 2 -> 4.
     * */
    //https://medium.com/google-developer-experts/
    // hands-on-jetpack-compose-visualtransformation-to-create-a-phone-number-formatter-99b0347fc4f6
    //https://medium.com/@banmarkovic/
    // how-to-create-currency-amount-input-in-android-jetpack-compose-1bd11ba3b629
    //https://developer.android.com/reference/kotlin/
    // androidx/compose/ui/text/input/VisualTransformation
    override fun originalToTransformed(offset: Int): Int =
        when (offset) {
            0, 1, 2 -> currencyOffset + defaultValueOffset
            else -> currencyOffset + offset + decimalSeparatorOffset + calculateThousandsSeparatorCount(
                originalIntegerLength
            )
        }

    /**
     * it must return a value between 0 and the original length
     * or it will return an exception
     * //es el offset para que siempre este al final
     * */
    override fun transformedToOriginal(offset: Int): Int {
        var calculated = originalIntegerLength +
                calculateThousandsSeparatorCount(originalIntegerLength) + 2

        if (originalTextLength < calculated) {
            calculated = originalTextLength
        }
        if (calculated < 0) {
            calculated = 0
        }
        return calculated
    }


    private fun calculateThousandsSeparatorCount(
        intDigitCount: Int
    ) = max((intDigitCount - 1) / 3, 0)
}

And the input:

package com.intecanar.candida.ui.newbrand.product.components

import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.TextField
import androidx.compose.material.TextFieldDefaults
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Close
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.runtime.getValue
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp
import androidx.core.text.isDigitsOnly
import com.intecanar.candida.R
import com.intecanar.candida.ui.theme.CandidaTheme
import com.intecanar.candida.ui.theme.Price

@Composable
fun MyAmountInput(
    modifier: Modifier = Modifier,
    initialPrice: Float = 0.0f,
    priceState: MutableState<String> = rememberSaveable {
        mutableStateOf( priceValueToStringFloat(initialPrice) )
    },
    caption: String, maxInput: Int,
    captionFontSize: TextUnit = 20.sp,
    innerTextFontSize: TextUnit = 25.sp,
    maxLengthFontSize: TextUnit = 15.sp,
    notifyNewValue: (Float) -> Unit,
) {
    Column(modifier = modifier) {
        val colors = MaterialTheme.colors
        val numberCommaTransformation = remember {
            NumberCommaTransformation(showCurrency = true)
        }

        val visualTransformation by remember(priceState.value) {
            mutableStateOf(
                if (priceState.value.isBlank()) {
                    VisualTransformation.None
                } else {
                    numberCommaTransformation
                }
            )
        }

        val primaryColor = colors.primary
        val alphaColor = 0.12f
        val primaryOpaque = colors.primary.copy(alpha = alphaColor)

        Text(
            text = caption,
            modifier = Modifier
                .fillMaxWidth()
                .padding(bottom = 4.dp),
            textAlign = TextAlign.Start,
            color = primaryColor,
            fontSize = captionFontSize,
            fontWeight = FontWeight.W600,
        )
        TextField(
            modifier = Modifier.fillMaxWidth().testTag("TextField $caption"),
            value = priceState.value,

            colors = TextFieldDefaults.textFieldColors(
                backgroundColor = primaryOpaque,
                cursorColor = primaryColor,
                disabledLabelColor = primaryOpaque,
                //hide the indicator
                focusedIndicatorColor = Color.Transparent,
                //bottom line color
                unfocusedIndicatorColor = Color.Transparent
            ),
            onValueChange = { newPrice: String ->
                if (newPrice.length <= maxInput) {
                    if (newPrice.isDigitsOnly()) {
                        if (newPrice.startsWith("0")) {
                            priceState.value =  ""
                        } else {
                            priceState.value = newPrice
                        }
                        notifyNewValue(
                            myAmountInputToFloat(priceState.value)
                        )
                    } else {
                        //Log.i("AMOUNT", "2BBBBB")
                    }
                }
            },
            shape = RoundedCornerShape(8.dp),
            singleLine = true,
            trailingIcon = {
                if (priceState.value.isNotEmpty()) {
                    IconButton(onClick = {
                        priceState.value = ""
                        notifyNewValue(0.0f)
                    }, modifier = Modifier.testTag("X IconButton $caption")) {
                        Icon(
                            imageVector = Icons.Outlined.Close,
                            contentDescription = stringResource(id = R.string.cd_close_text)
                        )
                    }
                }
            },
            textStyle = TextStyle(
                fontFamily = Price,
                fontSize = innerTextFontSize,
                fontWeight = FontWeight.W400,
                color = primaryColor
            ),
            keyboardOptions = KeyboardOptions.Default.copy(
                keyboardType = KeyboardType.NumberPassword,
            ),
            //Only transform the visual part do not affect the textState
            visualTransformation = visualTransformation,
        )
        Text(
            text = "${priceState.value.length} / $maxInput",
            modifier = Modifier
                .fillMaxWidth()
                .padding(top = 4.dp).testTag("Remaining Text $caption"),
            textAlign = TextAlign.End,
            fontWeight = FontWeight.W600,
            color = primaryColor,
            fontFamily = Price,
            fontSize = maxLengthFontSize,
        )
    }
}

@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun MyAmountInputPreview() {
    CandidaTheme {
        Column(
            Modifier
                .background(color = MaterialTheme.colors.background) //OK
        ) {
            MyAmountInput(
                caption = "Amount",
                initialPrice = 45484.13f, maxInput = 110,
                notifyNewValue = { }
            )
        }
    }
}

@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun MyAmountInputEmptyPreview() {
    CandidaTheme {
        Column(
            Modifier
                .background(color = MaterialTheme.colors.background) //OK
        ) {
            MyAmountInput(
                caption = "Amount",
                initialPrice = 0.0f, maxInput = 110,
                notifyNewValue = { }
            )
        }
    }
}