Decimal Input Formatting with Jetpack Compose's VisualTransformation

tuvakov

Jemshit Tuvakov

Posted on March 30, 2023

Decimal Input Formatting with Jetpack Compose's VisualTransformation

When dealing with (long) decimal numbers we usually want to format them by adding proper separators so it’s easy for the users to read. Currently there is no built-in functionality in Compose to support this feature. However Compose provides us with VisualTransformation feature for altering visual output in text fields. In this short article we will see how to use this feature to achieve proper decimal formatting.

There is a great Medium article by Ban Markovic which is inspiration for this article and covers similar situation. His solution focuses on currency formatting thus little strict on the input format. If you are dealing with formatting currency inputs please refer to Ban’s article.

Decimal Keyboard Type

Let’s start with simple input text field with keyboard type is set to decimal input.



@Composable
fun DecimalInputField(modifier: Modifier = Modifier) {
    var text by remember {
        mutableStateOf("")
    }

    OutlinedTextField(
        modifier = modifier,
        value = text,
        onValueChange = { text = it },
        keyboardOptions = KeyboardOptions(
            keyboardType = KeyboardType.Decimal
        )
    )
}


Enter fullscreen mode Exit fullscreen mode

As you can see there is nothing fancy going on here. One key thing we need to be aware of is that KeyboardType.Decimal does not guarantee correct decimal input. Concretely, user can enter more than one decimal separator, random thousands separators or some other undesired inputs like so in the below image.

Incorrect decimal input screenshot

Thus before formatting the decimal we need to make sure that it is in the correct form. We are going to perform this clean-up process before we set the new value of the input field. To keep things tidy, I will make a class called DecimalFormatter and write related functions in that class.



class DecimalFormatter(
    symbols: DecimalFormatSymbols = DecimalFormatSymbols.getInstance()
) {

    private val thousandsSeparator = symbols.groupingSeparator
    private val decimalSeparator = symbols.decimalSeparator

    fun cleanup(input: String): String {

        if (input.matches("\\D".toRegex())) return ""
        if (input.matches("0+".toRegex())) return "0"

        val sb = StringBuilder()

        var hasDecimalSep = false

        for (char in input) {
            if (char.isDigit()) {
                sb.append(char)
                continue
            }
            if (char == decimalSeparator && !hasDecimalSep && sb.isNotEmpty()) {
                sb.append(char)
                hasDecimalSep = true
            }
        }

        return sb.toString()
    }
}


Enter fullscreen mode Exit fullscreen mode

The code is pretty self-explanatory. Let me just explain the rules we’re following here.

  1. If the input something non-digit then just return empty string.
  2. If the input is consists of consecutive zeros then return single zero.
  3. Allow only digit inputs with one decimal separator (in this case the first one).

Now let’s add this clean up process to our decimal input field.



@Composable
fun DecimalInputField(
    modifier: Modifier = Modifier,
    decimalFormatter: DecimalFormatter
) {

    var text by remember {
        mutableStateOf("")
    }

    OutlinedTextField(
        modifier = modifier,
        value = text,
        onValueChange = {
            text = decimalFormatter.cleanup(it)
        },
        keyboardOptions = KeyboardOptions(
            keyboardType = KeyboardType.Decimal
        )
    )
}


Enter fullscreen mode Exit fullscreen mode

VisualTransformation

As mentioned earlier VisualTransformation is a feature that allows us to change the visual output of the input field. It is just an interface with single method called filter(). Our original input is passed to this method and transformed one is returned. It is important to point out that VisualTransformation does not change the input value of the field. It just alters the visual output. In other words, in the context of this article our original input value (stored in text variable) will not have thousands separators. Let’s dive into the code.

First let’s see the code that actually inserts thousands separators to the decimal number. As you can see below formatForVisual method handles this task and we call this method in VisualTransfromation class. The code is pretty simple. Since input string goes through the clean-up process before being passed to formatForVisual method we can assume that it is in the correct form. We just split the string according to the decimal separator then add thousands separators to integer part and then concatenate the new integer part and decimal part.



class DecimalFormatter(
    symbols: DecimalFormatSymbols = DecimalFormatSymbols.getInstance()
) {

    private val thousandsSeparator = symbols.groupingSeparator
    private val decimalSeparator = symbols.decimalSeparator

    fun cleanup(input: String): String {
        // Refer above snippet for the implementation.
    }

    fun formatForVisual(input: String): String {

        val split = input.split(decimalSeparator)

        val intPart = split[0]
            .reversed()
            .chunked(3)
            .joinToString(separator = thousandsSeparator.toString())
            .reversed()

        val fractionPart = split.getOrNull(1)

        return if (fractionPart == null) intPart else intPart + decimalSeparator + fractionPart
    }
}


Enter fullscreen mode Exit fullscreen mode

At last, we can look into the VisualTransformation . As you can see we implemented the interface as DecimalInputVisualTransformation. And since we handle the formatting logic in DecimalFormatter code the filter() method is pretty concise. First we format the input text and make new annotated string that holds the formatted number. We make sure that the new annotated string follows style of the original input text.

Another important thing we need to consider is the position of the cursor. I think the following example would help us better understand the situation. Imagine user moves the cursor to the position after the thousands separator and clicks “delete”. Since original input doesn’t have a thousands separator this position maps to the position between digits “2” and “3”. Thus when user clicks “delete” the digit “2” would be deleted and preferably cursor should stay after the digit “1” in the original input and after the thousands separator in the visual output.



// Original input before deletion
input = 12<cursor>345.67

// Visual output before deletion
output = 12,<cursor>345.67

---

// Original input after deletion
input = 1<cursor>345.67

// Visual output after deletion
output = 1,<cursor>345.67


Enter fullscreen mode Exit fullscreen mode

We handle this with OffsetMapping interface. According to the documentation OffsetMapping provides bidirectional offset mapping between original and transformed text. Here, to keep things simple we always want the cursor stay at the end of the text. As you can see in the FixedCursorOffsetMapping class we just return the lengths of the texts which allows us to fix the cursor to the end.

Finally we make a TransformedText object out formatted text and offset mapping instance and return.



class DecimalInputVisualTransformation(
    private val decimalFormatter: DecimalFormatter
) : VisualTransformation {

    override fun filter(text: AnnotatedString): TransformedText {

        val inputText = text.text
        val formattedNumber = decimalFormatter.formatForVisual(inputText)

        val newText = AnnotatedString(
            text = formattedNumber,
            spanStyles = text.spanStyles,
            paragraphStyles = text.paragraphStyles
        )

        val offsetMapping = FixedCursorOffsetMapping(
            contentLength = inputText.length,
            formattedContentLength = formattedNumber.length
        )

        return TransformedText(newText, offsetMapping)
    }
}

private class FixedCursorOffsetMapping(
    private val contentLength: Int,
    private val formattedContentLength: Int,
) : OffsetMapping {
    override fun originalToTransformed(offset: Int): Int = formattedContentLength
    override fun transformedToOriginal(offset: Int): Int = contentLength
}


Enter fullscreen mode Exit fullscreen mode

Let’s put it together



@Composable
fun DecimalInputField(
    modifier: Modifier = Modifier,
    decimalFormatter: DecimalFormatter
) {

    var text by remember {
        mutableStateOf("")
    }

    OutlinedTextField(
        modifier = modifier,
        value = text,
        onValueChange = {
            text = decimalFormatter.cleanup(it)
        },
        keyboardOptions = KeyboardOptions(
            keyboardType = KeyboardType.Decimal,
        ),
        visualTransformation = DecimalInputVisualTransformation(decimalFormatter)
    )
}


Enter fullscreen mode Exit fullscreen mode

Additionally, here some unit tests for the DecimalFormatter class.



class DecimalFormatterTest {

    private val subject = DecimalFormatter(symbols = DecimalFormatSymbols(Locale.US))

    @Test
    fun `test cleanup decimal without fraction`() {
        val inputs = arrayOf("1", "123", "123131231", "3423423")
        for (input in inputs) {
            val result = subject.cleanup(input)
            assertEquals(input, result)
        }
    }

    @Test
    fun `test cleanup decimal with fraction normal case`() {
        val inputs = arrayOf(
            "1.00", "123.1", "1231.31231", "3.423423"
        )

        for (input in inputs) {
            val result = subject.cleanup(input)
            assertEquals(input, result)
        }
    }

    @Test
    fun `test cleanup decimal with fraction irregular inputs`() {
        val inputs = arrayOf(
            Pair("1231.12312.12312.", "1231.1231212312"),
            Pair("1.12312..", "1.12312"),
            Pair("...12..31.12312.123..12.", "12.311231212312"),
            Pair("---1231.-.-123-12.1-2312.", "1231.1231212312"),
            Pair("-.--1231.-.-123-12.1-2312.", "1231.1231212312"),
            Pair("....", ""),
            Pair(".-.-..-", ""),
            Pair("---", ""),
            Pair(".", ""),
            Pair("      ", ""),
            Pair("     1231.  -   12312.   -   12312.", "1231.1231212312"),
            Pair("1231.  -   12312.   -   12312.     ", "1231.1231212312")
        )

        for ((input, expected) in inputs) {
            val result = subject.cleanup(input)
            assertEquals(expected, result)
        }
    }

    @Test
    fun `test formatForVisual decimal without fraction`() {
        val inputs = arrayOf(
            Pair("1", "1"),
            Pair("12", "12"),
            Pair("123", "123"),
            Pair("1234", "1,234"),
            Pair("12345684748049", "12,345,684,748,049"),
            Pair("10000", "10,000")
        )

        for ((input, expected) in inputs) {
            val result = subject.formatForVisual(input)
            assertEquals(expected, result)
        }
    }

    @Test
    fun `test formatForVisual decimal with fraction`() {
        val inputs = arrayOf(
            Pair("1.0", "1.0"),
            Pair("12.01723817", "12.01723817"),
            Pair("123.999", "123.999"),
            Pair("1234.92834928", "1,234.92834928"),
            Pair("12345684748049.0", "12,345,684,748,049.0"),
            Pair("10000.0009", "10,000.0009"),
            Pair("0.0009", "0.0009"),
            Pair("0.0", "0.0"),
            Pair("0.0100008", "0.0100008"),
        )

        for ((input, expected) in inputs) {
            val result = subject.formatForVisual(input)
            assertEquals(expected, result)
        }
    }
}


Enter fullscreen mode Exit fullscreen mode

Thank your for your attention. I hope you find this post helpful. You can reach the GitHub repo here.

The cover image is courtesy of Mika Baumeister

💖 💪 🙅 🚩
tuvakov
Jemshit Tuvakov

Posted on March 30, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related