Add Thousand-Separators for Number Inputs in Jetpack Compose
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
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:
- Converting the real value into the desired format that is going to be displayed.
- 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, and a great set of credit card transformations is here.
Formatting credit card number input in Jetpack compose Android
Benyam ・ Aug 31 ・ 5 min read
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.
\B(?=(?:\d{3})+(?!\d))
Let's explain this simple pattern with the help of regex101:
-
\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.
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
.
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.
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.
//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:
/* 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.
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
Also, you can check out the full implementation.