Master the UI Magic: Real-Time Data Formatting in Jetpack Compose
From VisualTransformation to OutputTransformation: How to format bank cards, phone numbers, and dates like a pro.
We’ve all been there: typing a 16-digit credit card number into a text field, squinting to check for errors. Without spaces, it’s a cognitive mess. As developers, we want formatting to be seamless, but traditionally, managing cursors and “hidden” characters has been a notorious headache.
There is an ongoing shift happening in how Jetpack Compose handles this. While many of us use VisualTransformation today, the OutputTransformation API is emerging in experimental text components to make this logic more intuitive.
The Strategy: Raw State, Polished UI
The goal is simple: Keep your source of truth clean (just the raw digits) while the user sees a formatted string.
- State:
1234567812345678 - UI:
1234 5678 1234 5678
⚠️ A Note on API Stability
Currently, OutputTransformation is part of the Experimental Foundation text APIs (built around TextFieldState and TextFieldBuffer). If you are building for production today using the stable TextField, you will likely still use VisualTransformation. Let’s look at both.
The “Future Way”: OutputTransformation (Experimental)
In the experimental rewrite of Compose Text, formatting becomes more declarative. Instead of calculating complex offset maps manually, we manipulate a buffer directly.
// Note: Requires Experimental Foundation APIs (BasicTextField2/TextFieldState)
// This example assumes digit-only input
val CreditCardTransformation = OutputTransformation {
// Logic: Insert a space after every 4th digit.
// We iterate backwards to avoid shifting indices
// of the characters we haven't reached yet!
val originalLength = length
for (i in (originalLength - 1) downTo 1) {
if (i % 4 == 0) {
insert(i, " ")
}
}
}The “Stable Way”: VisualTransformation
Since we need code that works in production right now, here is how you handle a credit card format using the stable API. This requires an OffsetMapping to tell Compose where the cursor should actually live when "ghost" characters are added.
class CreditCardVisualTransformation : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
val rawText = text.text
var out = ""
for (i in rawText.indices) {
out += rawText[i]
if (i % 4 == 3 && i != rawText.lastIndex) out += " "
}
val offsetTranslator = object : OffsetMapping {
override fun originalToTransformed(offset: Int): Int {
return offset + (offset / 4).coerceAtMost((out.length - rawText.length))
}
override fun transformedToOriginal(offset: Int): Int {
return offset - (offset / 5).coerceAtMost(out.length - rawText.length)
}
}
return TransformedText(AnnotatedString(out), offsetTranslator)
}
}When NOT to use Transformations
If your input needs strict validation (like rejecting letters entirely), handle that in your onValueChange logic or your TextFieldState filter. Transformations are strictly for the visual layer; they shouldn't be your "authority" on what data is allowed.
💡 UX Tip: For the smoothest experience, always pair formatted inputs with the correct keyboard type and autofill hints:
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, autoFillHints = AutofillReference.CreditCardNumber)
🙋♂️ Frequently Asked Questions (FAQs)
Why did you iterate backwards in the OutputTransformation example?
Great catch! If you insert a space at index 4, the character that was at index 5 moves to index 6. By iterating backwards, we ensure the indices of the digits we haven’t processed yet stay exactly where they are.
Does this change the value of my state variable?
No. Whether you use VisualTransformation or OutputTransformation, your state remains clean (e.g., "12345678").
Can I use this for phone numbers or dates?
Absolutely. You would just adjust the logic to insert specific characters (like brackets or dashes) at specific positions. However, the more symbols you add, the more complex your OffsetMapping math becomes in the stable API—which is exactly why OutputTransformation is so exciting.
💬 Let’s Discuss!
- Are you currently using
VisualTransformation, or have you started experimenting with the newTextFieldState? - What is the most complex input mask you’ve had to build?
- Would you like to see a follow-up post on creating a Regex-based formatter for more dynamic inputs?
Drop a comment below — let’s build better Android UIs together!
📘 Master Your Next Technical Interview
Since Java is the foundation of Android development, mastering DSA is essential. I highly recommend “Mastering Data Structures & Algorithms in Java”. It’s a focused roadmap covering 100+ coding challenges to help you ace your technical rounds.
- E-book (Best Value! 🚀): $1.99 on Google Play
- Kindle Edition: $3.49 on Amazon
- Also available in Paperback & Hardcover.

Comments
Post a Comment