Decimal Input Formatting with Jetpack Compose's VisualTransformation
Tags: android, compose, decimal
Published: 2023-03-30 13:06:14

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](https://medium.com/@banmarkovic/how-to-create-currency-amount-input-in-android-jetpack-compose-1bd11ba3b629) 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.

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

    OutlinedTextField(
        modifier = modifier,
        value = text,
        onValueChange = { text = it },
        keyboardOptions = KeyboardOptions(
            keyboardType = KeyboardType.Decimal
        )
    )
}
```
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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/sr5eghmgikhfnn869nz5.png)

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.

```kotlin
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()
    }
}
```

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.

```kotlin
@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

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.

```kotlin
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
    }
}
```

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
```

We handle this with `OffsetMapping` interface. According to the [documentation](https://developer.android.com/reference/kotlin/androidx/compose/ui/text/input/OffsetMapping) `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.

```kotlin
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
}
```

### Let’s put it together

```kotlin
@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)
    )
}
```

Additionally, here some unit tests for the `DecimalFormatter` class. 

```kotlin
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)
        }
    }
}
```

Thank your for your attention. I hope you find this post helpful. You can reach the GitHub repo [here](https://github.com/tuvakov/compose-vt-decimal-formatter).


_The cover image is courtesy of [Mika Baumeister](https://unsplash.com/photos/Wpnoqo2plFA)_