Morphing Magic: Visually Transforming TextFields in Compose 🧙🏽‍♂️

Jesseosile
7 min readJul 22, 2024

--

Hello Fellow Composers ! 👋

In a previous article, i discussed automatically adding dashes to text in an input field. It then made me wonder, “Why not try to replicate this in Jetpack Compose” 🤔. I did and this took me down the rabbit hole that is Visual Transformation. This’ll be our focus in this article 😁.

Trial and Error. 🥲

Required Result

Following up on our previous article, the GIF above demonstrates our desired outcome for formatting a special code. Here, the only valid input characters are digits and dashes. Dashes are automatically added after every two digits, except for the last two digits. The maximum length of this code is six digits, excluding the dashes.

To achieve this functionality, I started by creating a composable screen called Screen and a function called formatText.

Screen displays an OutlinedTextField with a descriptive text above it.
simply formats the input text adding dashes after every 2 digit besides the last 2 digits.

formatText takes an input string, adds dashes after every 2 digits and returns this formatted value as a StringBuilder.

Screen composable simply displays an OutlinedTextField with a descriptive text above it. When the user enters values into the text field, it checks the current value (from our onValueChanged lambda) ensuring that the length is no more than 8, If it is:

  • We simply return the value as is i.e. if we have “12–23–45” as the current value, we just return that.
  • Else, we format the current value using the formatText function.

Let’s run the app and see what we’ve got! 😁

The Unwanted result 😭.

Whoops!!! Not what we expected. 😭

So yes…the dash is automatically added after every 2 digits. However, once the first dash is appended (after the first 2 digits), there’s a misplacement of the cursor.

Why though? 🤔

Interestingly, there’s no cursor placement issue when we directly type the value as “12–34”. This is because each character entered manually is recognized individually, and the cursor is positioned accordingly. However, in our automatic formatting scenario, when we enter two digits followed by a third one, the code adds a dash for us. This automatic insertion isn’t recognized as a separate character input, leading to the cursor misplacement.

Meaning, when the code formatting happens what we see and expect is “12–3” but what the cursor sees is “123” hence it’s improper placement.

How do we resolve this? 🧐

That’s where VisualTransformation comes in!!! 🥳

What is VisualTransformtion?

Simply put, it is a tool that changes how a text looks in a TextField without changing it’s actual value hence visually transforming the text.

When “1234”is entered into a TextField, VisualTransformation would be responsible for displaying it as “****” to the user but it remains “1234” under the hood.

Before moving ahead, there are a couple of concepts we need to understand, they are:

  1. filter
  2. AnnotatedString
  3. TransformedText
  4. OffsetMapping
  • filter: This is a function of the VisualTransformation interface. It simply takes the text to be transformed as an AnnotatedString, transforms this text based on the logic defined inside of it and returns the result as TransformedText which includes the transformed AnnotatedString and an OffsetMapping to manage cursor behavior.
  • AnnotatedString: Think of it as a special kind of string that not only holds the text itself but also includes decorations or formatting information (metadata) for parts of the text. These can be things like making text bold, italic, colored, or adding other styles.
  • TransformedText: It is the result of transforming the initial text to the new text, it takes an AnnotatedString and OffsetMapping as parameters. The AnnotatedString is the new transformed text and OffsetMapping is used to connect the positions of the transformed text back to the original text.
  • OffsetMapping: This here is our main concern for this article, the solution to our problem 😁.

What is OffsetMapping?

Imagine you have a paper and a magic pen, when you write on the paper it automatically converts what ever you write to an asterisk i.e. if you write “1234” it converts the writing to “****”. The magic pen here represents VisualTransformation.

After the conversion, chances are you’d still want to know the original position of each number if you want to find them. This is where OffsetMapping comes in, it maps the position of the original number to it’s respective asterisk like so below:

Offset Mapping diagram

Simply put, it is an interface that helps map the positions between the original text and the transformed text. It tells the system how to convert positions in the transformed text back to positions in the original text and vice versa. It takes to functions that have to be overridden:

  • originalToTransformed: Maps the position from the original text to the transformed text.
  • transformedToOriginal: Maps the position from the transformed text back to the original text.

And why is it such a big deal to us? When users interact with the text (e.g., placing the cursor, selecting text), the system needs to know the correct positions in the original text. OffsetMapping ensures that the positions remain accurate, even after the visual transformation. This’ll help solve our cursor position problem.

VisualTransformation in Action!!! 🚀💪

We’re going to create a class called SpecialCodeVisualTransformation and also a String extension function called addDashes.

The addDashes function simply takes a given string and adds a dash after every 2 digits within the string. It then returns the result as an AnnotatedString which is utilized inside our custom VisualTransformation class.

This is our custom VisualTransformation class SpecialCodeVisualTransformation responsible for transforming our text and maintaining cursor position.

First, the entered text is received as an AnnotatedString. We check the text length to ensure it doesn’t exceed the maximum allowed length. If the length is greater than 6 characters, we extract only the first 6 characters. For example, “1652389” would be trimmed to “165238”. If the length is less than or equal to 6, the entire string is used as is. For instance, “2345” remains “2345”. This trimmed text is then stored in the trimmedText variable.

Then we call our addDashes function on trimmedText to add the dashes where it should be. We return the transformed result as TransformedText passing the addedDashes variable to TransformedText and also passing the OffsetMapping object to it. Let’s take a closer look at out OffsetMapping object.

It takes 2 functions, originalToTransformed and transformedToOriginal.

  • originalToTransformed: It takes a parameter called offset. offset here represents the position of the cursor in the original text (text before transformation) and returns an Integer which represents the new offset after transformation occurs.

As seen from the above diagram, when we enter a number the position of the number is 0 and that of the cursor is 1. The cursor position here is the offset passed as a parameter into the originalToTransformed function.

When we enter a new set of numbers, we see that offset for the original text changes, it becomes 3 which is the current position of the cursor.

For our transformed text, we see that the dash has been automatically added and the offset changes again. That is the point of our originalToTransformed, it tries to communicate to the TextField what the current cursor should be based on the transformation logic inside of it.

According to our logic, we’re saying:

  • If the offset(current cursor position) is less than or equal to 2, this means that there are only 2 digits inside the TextField. So return offset as is.
  • If the offset(current cursor position) is less than or equal to 4, this means that there could be 3 to 4 digits in TextField. This means we need to cater for the added dash (this is because if we enter “123” into the TextField, we expect “12–3” as the eventual output). We return the current offset Plus 1(added dash) as the new offset.
  • If the offset(current cursor position) is less than or equal to 6, this means that there could be 5 to 6 digits in TextField. We have to cater for the added dashes here as well (in this case, we would be dealing with 2 dashes like so “12–34–5”). We return the current offset Plus 2(added dashes) as the new offset.
  • If none of these conditions are the case, just return the new offset as 8 (this means we have a complete special code “12–34–56”). The cursor should be positioned at the extreme end which is index 8.
  • transformedToOriginal: It works similar to originalToTransformed, the only difference being that we try to calculate the current position of the cursor in the transformed text(current offset) and then determine where the cursor should placed in the original text (new offset).

Let’s See All of This In Action!!! 🚀

We make only 2 changes to our OutlinedTextField:

  • We simply pass our custom VisualTransformation to the OutlinedTextField.
  • We now only update value inside onValueChange(if the entered text is valid) instead of formatting the text and updating.

We run the app!! 😁

The Final Result.

Yesssss!!!! The app works as expected. 🥳🥳

NOTE: VisualTransformation only modifies the visual appearance of our text and not the actual underlying data i.e. The user sees “12–34–56” but the actual data under the hood still remains “123456”

That’s all fellow composers and see you on the next one! 👋

--

--