How to create Currency Amount Input in Android Jetpack Compose?
When it comes to handling money with your mobile banking and finance apps, you can never be too cautious. That’s why a lot of apps try to limit and simplify user’s action especially while entering the sending amount.
In the mobile world, there is a common format for currency amount input fields. This format calls for two fixed decimal places in the amount and only permits the user to add digits at the end of the input (shifts the old digits to the left by one).
This post’s objective is to demonstrate how to include this kind of input into your Android Jetpack Compose project. In case you prefer to look at the Github repo of this example, you can find it here.
Solution
In order to limit the user on entering only digits in our TextField, we need to setup keyboardType to NumberPassword.
keyboardOptions = KeyboardOptions(keyboardType=KeyboardType.NumberPassword)
Now that we enabled user only to input digits, we want to format that input with thousands and decimal separators. Because we don’t want to change the original input value (which is basically array of digits), we will use Jetpack Compose feature for TextField called VisualTransformation.
VisualTransformation
“Interface used for changing visual output of the input field.” — Android documentation
Core functionality of VisualTransformation is filter(text: AnnotatedString) method, that receives original input and returns TransformedText that we want to show to user.
First thing we need to do is to get decimal and thousands separator characters.
val symbols = DecimalFormat().decimalFormatSymbol
val thousandsSeparator = symbols.groupingSeparator
val decimalSeparator = symbols.decimalSeparators
Since we are receiving original input from user, which is basically array of digits, we need to separate that string into two pieces: integer part and decimal part. Here are few examples to better understand what am I saying.
Original input => Integer part + Decimal part => Output
123 => 1 + 23 => 1.23
542010 => 5420 + 10 => 5420.10
1 => 0 + 01 => 0.01
With an eye toward getting our int part, we need to check if original array of digits is longer than two, and then we can take every digit except last two. If the original array is not longer than two, it means our integer part is zero.
val inputText = text.text
val intPart = if (inputText.length > 2) {
inputText.subSequence(0, inputText.length - 2)
} else {
"0"
}
We are going to do the similar thing for our decimal part, except this time, we are going to take our two last digits from original array. If the array doesn’t have the length of two, we will add “0” character in front.
Recommended by LinkedIn
var fractionPart = if (inputText.length >= 2) {
inputText.subSequence(inputText.length - 2, inputText.length)
} else {
inputText
}
// Add zeros if the fraction part length is not 2
if (fractionPart.length < 2) {
fractionPart = fractionPart.padStart(2, '0')
}
Now that we have our decimal part and integer part, the only thing left for formatting this number is to add thousands separators on integer part. In this example I’m using regex, but you are free to do it manually by going from the back of the string and putting the thousand separator after every third digit.
val thousandsReplacementPattern = Regex("\\B(?=(?:\\d{3})+(?!\\d))")
val formattedIntWithThousandsSeparator =
intPart.replace(
thousandsReplacementPattern,
thousandsSeparator.toString()
)
Finally, the integer portion of the output string has been formatted, and the decimal portion has been obtained. New string is ready to be forged.
val newText = AnnotatedString(
formattedIntWithThousandsSeparator + decimalSeparator + fractionPart,
text.spanStyles,
text.paragraphStyles
)
But, there is still one more thing left for us to do in order to finish our implementation of our VisualTransformation. We need to provide OffsetMapping interface rules.
OffsetMapping
“Provides bidirectional offset mapping between original and transformed text.” — Android documentation
class ThousandSeparatorOffsetMapping(
val originalIntegerLength: Int) : OffsetMapping {
override fun originalToTransformed(offset: Int): Int =
when (offset) {
0, 1, 2 -> 4
else -> offset + 1 + calculateThousandsSeparatorCount(originalIntegerLength)
}
override fun transformedToOriginal(offset: Int): Int =
originalIntegerLength +
calculateThousandsSeparatorCount(originalIntegerLength) +
2
private fun calculateThousandsSeparatorCount(
intDigitCount: Int
) = max((intDigitCount - 1) / 3, 0)
}
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.
Since our OffsetMapping is done, we can finally finish our VisualTransformation and filter() method by returning TransformedText.
val offsetMapping = ThousandSeparatorOffsetMapping(
originalIntegerLength = intPart.length
)
return TransformedText(newText, offsetMapping)
Final touch
Now we can pass our VisualTransformation to any TextField we want to behave like Currency Amount Input. The only thing left to do is to forbid the user from entering leading zeroes for the amount, and voila, you have your final result.
var text by remember { mutableStateOf("") }
TextField(
value = text,
onValueChange = {
text = if (it.startsWith("0")) {
""
} else {
it
}
},
visualTransformation = CurrencyAmountInputVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.NumberPassword
)
)
I do hope you find this post helpful and insightful. In case you need Github example of the working feature, you can find it here.
Thank you for your time and if you have any feedback feel free to comment!