Add Thousand-Separators for Number Inputs in Jetpack Compose
Tags: android, compose, kotlin, textfield
Published: 2021-10-08 10:33:10

## Introduction
In most financial apps, numerical text fields play a significant role. People want to register their orders precisely in these input boxes, and a consistent experience is vital. One of the basic necessities for these fields is thousand-separators inserted between the numbers while the user is typing. The main challenge is usually keeping the cursor in its correct position and preventing annoying cursor jumps.

Thanks to the new [`VisualTransformation`](https://developer.android.com/reference/kotlin/androidx/compose/ui/text/input/VisualTransformation) option added to the `TextField`s in Jetpack Compose, this task has gotten easier than traditional approaches using `EditText`. In this post, I have explained the result of migrating my custom `EditText`, which is meant for currencies in a cryptocurrency trader app, to the new facilities provided in Jetpack Compose.

In a nutshell, what happens in the new `TextField` is that it keeps the value input by the user, called original text, behind the scenes but displays a transformed version of it using the transformer. As a result, a `VisualTransformation` must consist of two parts:
1. Converting the real value into the desired format that is going to be displayed.
2. Translating each position in the original text to the transformed text and vice-versa. In this case, the component is able to place the cursor in the right position.

Both these jobs should take place in `filter` method, and the return value is of type `TransformedText`, which consists of the transformed text and the offset mapping.

A set of examples can be found in the [official document](https://developer.android.com/reference/kotlin/androidx/compose/ui/text/input/VisualTransformation), and a great set of credit card transformations is here. {% post benyam7/formatting-credit-card-number-input-in-jetpack-compose-android-2nal %}

## ThousandSeparatorVisualTransformation
Let's see how to implement a thousand-separator. In the beginning, I want to mention that as the implementation was meant to be working in a financial app, it has support for decimal places.

### Adding the separators
#### Regex
A trivial approach would be a loop traversing the text in the reverse order and adding commas after every three characters. However, as I like regex too much, I use this pattern that selects the positions appropriate for thousand separators too.
```regex
\B(?=(?:\d{3})+(?!\d))
```
Let's explain this simple pattern with the help of [regex101](https://regex101.com/r/hhJnAF/1):
* `\d{3}` means three digits. I'm not interested in capturing them (you will find out why), so marking it as a non-capturing group, `(?:\d{3})`. Therefore, `(?:\d{3})+` will match groups of characters made of digits with a count that is a multitude of three.
* `(?!\d)` is a negative lookahead. It matches an anchor point that is not a number, like the end of the text or a dot. Along with the previous group, it means the string of numbers with a length equal to a multitude of three and ending where the number ends. So it will not capture every triple in the middle of the number.
* Finally, a positive lookahead matches the positions that the pattern holds true. In our case, the places to put thousand-separators.
* `\B` ensures that last place is not included. We don't need a separator at the start of the number.

#### Converting the Integer Part
With the pattern, the rest of the work is quite straightforward. We use `replace` method on strings to put the commas in between.
```kotlin
override fun filter(text: AnnotatedString): TransformedText {
    val commaReplacementPattern = Regex("""\B(?=(?:\d{3})+(?!\d))""")
    val transformed = 
        text.text.replace(commaReplacementPattern, ",")
    // ...
}
```
But further considerations should be given. Localization is one of the essential parts of your app if it is going to be multilingual. So, instead of directly putting a hard-coded comma, we use `DecimalFormatSymbols`.
```kotlin
val symbols = DecimalFormat().decimalFormatSymbols
val comma = symbols.groupingSeparator
```

##### Why not using `DecimalFormat`?
In addition to the fact that this class requires parsing before formatting, there are some cases that it doesn't work well. For example, users may want to add several zeroes and then put a number at the left. In that case, the formatter clears all the meaningless zeroes, which deteriorates users' ​convenience.

### Decimal Places
As mentioned before, in many currencies, a fraction part is available. Also, we know that this part has limited decimal places, e.g., USD can only have two decimal places. So our transformer takes two parameters, `maxFractionDigits` and `minFractionDigits` (for enforced decimal places). It ends up with this part in the code.
```kotlin
val zero = symbols.zeroDigit
val minFractionDigits = min(this.maxFractionDigits, this.minFractionDigits)
fracPart = fracPart.take(maxFractionDigits).padEnd(minFractionDigits, zero)
```
Furthermore, there is an edge case, when the input has only a fraction part, so the user starts it with a dot. In this case, our strategy is to put a zero at the start of the text for better readability.
```kotlin
//Ensure there is at least one zero for integer places
val normalizedIntPart =
   ​if (intPart.isEmpty() && fracPart != null) zero.toString() else intPart
```

### All Together
Connecting the described pieces will end to the following code:
```kotlin
/* As android uses icu in the recent versions we just let DecimalFormat to
​* take care of the selection
​*/
private val symbols = DecimalFormat().decimalFormatSymbols

private val commaReplacementPattern = Regex("""\B(?=(?:\d{3})+(?!\d))""")


override fun filter(text: AnnotatedString): TransformedText {
   ​val comma = symbols.groupingSeparator
   ​val dot = symbols.decimalSeparator
   ​val zero = symbols.zeroDigit

   ​var (intPart, fracPart) = text.text.split(dot)
       ​.let { Pair(it[0], it.getOrNull(1)) }

   ​//Ensure there is at least one zero for integer places
   ​val normalizedIntPart =
       ​if (intPart.isEmpty() && fracPart != null) zero.toString() else intPart

   ​val integersWithComma = normalizedIntPart.replace(commaReplacementPattern, comma.toString())

   ​val minFractionDigits = min(this.maxFractionDigits, this.minFractionDigits)
   ​if (minFractionDigits > 0 || !fracPart.isNullOrEmpty()) {
       ​if (fracPart == null)
           ​fracPart = ""

       ​fracPart = fracPart.take(maxFractionDigits).padEnd(minFractionDigits, zero)
   ​}

   ​val newText = AnnotatedString(
       ​integersWithComma + if (fracPart == null) "" else ".$fracPart",
       ​text.spanStyles,
       ​text.paragraphStyles
   ​)
  ​// ...
}
```

### Translation of Offsets
After the transformation is done, we need to implement two offset mapping functions described above. The main idea here is correctly counting the added characters before a position. I'm not going to the details as they are pretty straightforward, and I suffice to the code provided below.
```kotlin
private inner class ThousandSeparatorOffsetMapping(
   ​val originalIntegerLength: Int,
   ​val transformedIntegersLength: Int,
   ​val transformedLength: Int,
   ​val commaIndices: Sequence<Int>,
   ​addedLeadingZero: Boolean
) : OffsetMapping {
   ​val commaCount = calcCommaCount(originalIntegerLength)

   ​val leadingZeroOffset = if (addedLeadingZero) 1 else 0

   ​override fun originalToTransformed(offset: Int): Int =
       ​// Adding number of commas behind the character
       ​if (offset >= originalIntegerLength)
           ​if (offset - originalIntegerLength > maxFractionDigits)
               ​transformedLength
           ​else
               ​offset + commaCount + leadingZeroOffset
       ​else
           ​offset + (commaCount - calcCommaCount(originalIntegerLength - offset))

   ​override fun transformedToOriginal(offset: Int): Int =
       ​// Subtracting number of commas behind the character
       ​if (offset >= transformedIntegersLength)
           ​min(offset - commaCount, transformedLength) - leadingZeroOffset
       ​else
           ​offset - commaIndices.takeWhile { it <= offset }.count()

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

## Final Result
![Demo](https://gist.githubusercontent.com/momt99/5e756f227ff3a813b5870260453fdb73/raw/2e3f4d5b955bcb34153854fb4c18354119d0f109/demo.gif)

Also, you can check out the full implementation.
{% gist https://gist.github.com/momt99/5e756f227ff3a813b5870260453fdb73 file=ThousandSeparatorVisualTransformation.kt %}