Blog Infos
Author
Published
Topics
Published

Photo by Jason Leung on Unsplash

 

Working on Android with currencies is fairly easy. There are built-in components that tell what is the default currency for our device, currency formatters, etc. Displaying currencies is easy. However, I have stumbled upon a challenge working on a new app feature.

I had to make a TextField that displays a price formatted with the user’s device currency. Sound easy?

Let’s list the requirements:

  • The user is allowed to enter digits only. No spaces, no special characters, no manually typed currency symbols.
  • As soon as the user types some digits, the outcome needs to be formatted as currency.

Take a look at the recording below to have a better understanding what is the goal. There are three different TextFields: first with default locale and USD currency, second with ENGLISH locale and USD currency, and third with ENGLISH locale and GBP currency.

I was typing ONLY digits on the keyboard

VisualTransfomation

TextField comes with a very handy class VisualTransformation that converts TextField String value into formatted value. One of the available transformations is PasswordVisualTransformation which turns entered values into dots. So I decided to use that!

In this article, I am going to explain how it works inside the code using comments. I believe that would be the easiest to understand. I have formatted the comments so they don’t require scrolling horizontally.

CurrencyVisualTransformation
import android.util.Log
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import androidx.core.text.isDigitsOnly
import java.text.NumberFormat
import java.util.Currency

/**
 * Visual filter for currency values. Formats values without fractions 
 * adding currency symbol
 * based on the provided currency code and default Locale.
 * @param currencyCode the ISO 4217 code of the currency
 */
private class CurrencyVisualTransformation(
    currencyCode: String
) : VisualTransformation {
     /**
     * Currency formatter. Uses default Locale but there is an option to set
     * any Locale we want e.g. NumberFormat.getCurrencyInstance(Locale.ENGLISH)
     */
    private val numberFormatter = NumberFormat.getCurrencyInstance().apply {
        currency = Currency.getInstance(currencyCode)
        maximumFractionDigits = 0
    }

    override fun filter(text: AnnotatedString): TransformedText {
        /**
         * First we need to trim typed text in case there are any spaces. 
         * What can by typed is also handled on TextField itself, 
         * see SampleUse code.
         */
        val originalText = text.text.trim()
        if (originalText.isEmpty()) {
            /**
             * If user removed the values there is nothing to format. 
             * Calling numberFormatter would cause exception.
             * So we can return text as is without any modification. 
             * OffsetMapping.Identity tell system that the number 
             * of characters did not change.
             */
            return TransformedText(text, OffsetMapping.Identity)
        }
        if (originalText.isDigitsOnly().not()) {
            /**
             * As mentioned before TextField should validate entered data 
             * but here we also protect the app from crashing if it doesn't 
             * and log warning.
             * Then return same TransformedText like above.
             */
            Log.w("TAG", "Currency visual transformation require using digits only but found [$originalText]")
            return TransformedText(text, OffsetMapping.Identity)
        }
        /**
         * Here is our TextField value transformation to formatted value. 
         * EditText operates on String so we have to change it to Int. 
         * It's safe at this point because we eliminated cases where
         * value is empty or contains non-digits characters.
         */
        val formattedText = numberFormatter.format(originalText.toInt())
        /**
         * CurrencyOffsetMapping is where the magic happens. See you there :)
         */
        return TransformedText(
            AnnotatedString(formattedText),
            CurrencyOffsetMapping(originalText, formattedText)
        )
    }
}

/**
 * Helper function prevents creating CurrencyVisualTransformation 
 * on every re-composition and use inspection mode 
 * in case you don't want to use visual filter in Previews.
 * Currencies were displayed for me in Preview but I don't trust them 
 * so that's how you could deal with it by returning VisualTransformation.None
 */
@Composable
fun rememberCurrencyVisualTransformation(currency: String): VisualTransformation {
    val inspectionMode = LocalInspectionMode.current
    return remember(currency) {
        if (inspectionMode) {
            VisualTransformation.None
        } else {
            CurrencyVisualTransformation(currency)
        }
    }
}

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

, ,

Cutting-Edge-to-Edge in Android 15: Using Previews and Testing in Jetpack Compose to Manage Insets.

With the advent of Android 15, edge-to-edge design has become the default configuration. Consequently, applications must be capable of accommodating window insets, including the system status bar and navigation bar, as well as supporting drawing…
Watch Video

Cutting-Edge-to-Edge in Android 15: Using Previews and Testing in Jetpack Compose to Manage Insets.

Timo Drick
Lead Android developer
Seven Principles Mobility GmbH

Cutting-Edge-to-Edge in Android 15: Using Previews and Testing in Jetpack Compose to Manage Insets.

Timo Drick
Lead Android develop ...
Seven Principles Mob ...

Cutting-Edge-to-Edge in Android 15: Using Previews and Testing in Jetpack Compose to Manage Insets.

Timo Drick
Lead Android developer
Seven Principles Mobility ...

Jobs

CurrencyOffsetMapping

Note: CurrencyOffsetMapping translates TextField caret position between original and formatted text. By controlling the caret position we do not allow modifying currency symbols.

import androidx.compose.ui.text.input.OffsetMapping

/**
 * CurrencyOffsetMapping is a class that maps offsets 
 * between an original text and its formatted version.
 *
 * @param originalText The original unformatted text.
 * @param formattedText The formatted text, which has the same content 
 *                      as originalText but with different
 *                      character positioning, due to added 
 *                      or removed formatting characters.
 */
class CurrencyOffsetMapping(originalText: String, formattedText: String) : OffsetMapping {
    private val originalLength: Int = originalText.length
    private val indexes = findDigitIndexes(originalText, formattedText)

    /**
     * Find the indexes of digits in the original text with respect 
     * to the formatted text.
     *
     * @param firstString The original unformatted text.
     * @param secondString The formatted text.
     * @return A list of indexes indicating the position of digits 
     *         in the secondString (formatted text).
     *         The order of indexes corresponds to the order of digits 
     *         in the original text.
     *         If a digit is not found in the secondString, 
     *         an empty list is returned.
     */
    private fun findDigitIndexes(firstString: String, secondString: String): List<Int> {
        val digitIndexes = mutableListOf<Int>()
        var currentIndex = 0
        for (digit in firstString) {
            // Find the index of the digit in the second string
            val index = secondString.indexOf(digit, currentIndex)
            if (index != -1) {
                digitIndexes.add(index)
                currentIndex = index + 1
            } else {
                // If the digit is not found, return an empty list
                return emptyList()
            }
        }
        return digitIndexes
    }
    
    /**
     * Maps an offset from the original text to its corresponding position 
     * in the formatted text.
     *
     * @param offset The offset in the original text.
     * @return The offset in the formatted text corresponding to the input 
     *         offset.
     *         If the input offset is beyond the length of the original text, 
     *         the last position in the formatted text is returned adding 1
     *         to set the caret after last digit.
     */
    override fun originalToTransformed(offset: Int): Int {
        /**
         * Example:
         * original 123
         * formatted $123
         * indexes [1,2,3]
         * caret position/offset is 1 which is here 1|23 in the original
         * in formatted text it will be offset=2 since all digits move by 1
         * because of the $ symbol at start
         * if caret is at the end of 123 we do not have index for it in indexes
         * so we take last value from indexes and add 1
         */
        if (offset >= originalLength) {
            return indexes.last() + 1
        }
        return indexes[offset]
    }

    /**
     * Maps an offset from the formatted text to its corresponding position 
     * in the original text.
     *
     * @param offset The offset in the formatted text.
     * @return The offset in the original text corresponding to the input 
     *         offset.
     *         If the input offset is beyond the length of the formatted text, 
     *         the length of the original text is returned.
     */
    override fun transformedToOriginal(offset: Int): Int {
         /**
         * Example 1:
         * original text 123
         * formatted text $123
         * indexes [1, 2, 3], index 0 is taken by $ symbol
         * if user tries to set caret before $ (offset = 0) 
         * which is not allowed
         * we have to find the closest allowed caret position which in that
         * case will be 1
         * 
         * Example 2:
         * original text 123
         * formatted text 123 USD
         * indexes [0,1,2] beyond that we have space and currency symbol
         * if user tries to set caret between U and S (offset=5) 
         * which is not allowed
         * we have to find the closest allowed caret which we cannot in indexes.
         * Thus we take the length of original text to set caret after 123
         */
        return indexes.indexOfFirst { it >= offset }.takeIf { it != -1 } ?: originalLength
    }
}

The below images help us understand how the caret offset position is transformed in the transformedToOriginal function.

transformedToOriginal, Example 1

 

transformedToOriginal, Example 2

 

Sample use
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.input.KeyboardType

private const val MAX_VALUE = 10000

/**
 * Sample how to apply visual transformation and validation logic you could use
 */
@Composable
fun SampleUse() {
    var value by remember { mutableStateOf("") }
    val currencyVisualTransformation = rememberCurrencyVisualTransformation(currency = "USD")
    TextField(
        value = value,
        onValueChange = { newValue ->
            /**
             * Trim entered value removing 0 at start and then remove
             * every characters that is not a digit
             * Update value only if it's empty or if value is not higher
             * than 10000
             */
            val trimmed = newValue.trimStart('0').trim { it.isDigit().not() }
            if (trimmed.isEmpty() || trimmed.toInt() <= MAX_VALUE) {
                value = trimmed
            }
        },
        /**
         * Keyboard doesn't matter much but make sense if we want
         * type digits only
         */
        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
        /**
         * Apply visual transformation here
         */
        visualTransformation = currencyVisualTransformation
    )
}

Here is also a short recording from the Tilt app I am working on. This custom bidding feature will be released soon. So download the app today and experience all the exciting new features for yourself!

Auction custom bidding at Tilt

 

Summary

I hope this article will help others understand better how visual transformation can be applied. I spent a few hours on this, so you don’t have to 🙂

Here is a GitHub gist without comment if you want to copy code more easily.

Again, happy coding! Cheers!

This article was previously published on proandroiddev.com

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog
In the world of Jetpack Compose, where designing reusable and customizable UI components is…
READ MORE
blog

How to animate BottomSheet content using Jetpack Compose

Early this year I started a new pet project for listening to random radio…
READ MORE
Menu