Blog Infos
Author
Published
Topics
Published

Photo by James Sutton on Unsplash

 

If you don’t care how the sausage is made, skip to the end for the implementation

Jetpack Compose has introduced the VisualTransformationAPI for formatting and transforming TextField input. I recently had the need to format a phone number as it was typed, so I went looking for an idiomatic solution.

Not wanting to reinvent the wheel, I went searching for a solution. Surely someone had solved this already, right? Well, not so much, it seems.

Every proposed implementation I could find seemed way over-engineered, or completely manual in its calculations. My brain just nopes out. Try it for yourself — give it a search.

Taking the time to understand it, much less maintain it somewhere down the line, seems like more of a time investment than I’m willing to make. There has to be a better way, right?

Let’s take a crack at implementing our own.

VisualTransformation

This class wants you to implement the filter function, which is for formatting the incoming, raw string to the representation for display. I wish they’d named this function something else, like format, but it is what it is.

The return value for this function wants an instance of TransformedText. Let’s see what that’s all about

TransformedText

We’ve got two parameters for the constructor here — AnnotatedString, and something called an OffsetMapping. The AnnotatedString is common enough, so I won’t go into too much detail here. This will just hold your formatted string in most instances, so you’ll end up with something like AnnotatedString(text = SomeFormatter.format(text), ...). Pretty simple. Now, what about the second parameter of TransformedText? Something called OffsetMapping.

OffsetMapping

Oh boy, let me tell you about the time vampire that is OffsetMapping. This thing is the real meat and potatoes of the transformation. This is where the magic happens.

All right, minor rant here — skip a few paragraphs if you don’t care.

First off, the name of the parameter really irks me: offset. Seems innocuous enough at first glance, but… what exactly are we offsetting? Let’s look at the docs:

Convert offset in original text into the offset in transformed text.
This function must be a monotonically non-decreasing function. In other words, if a cursor advances in the original text, the cursor in the transformed text must advance or stay there.

Monotonically non-decreasing. Gotcha. Glad they dumbed it down for me in the next sentence.

Okay, so if we use some context clues here, we can assume this is talking about the cursor offset for the TextField. Makes sense, right? If you start adding symbols and such to a phone number, the cursor position for the original text won’t be the same position in the transformed text.

As far as I can tell, the bi-directionality of the thing helps when the user highlights segments of text to copy / paste. Either way, it wants both, so we’ll give it both.

End rant.

If you search around online, you’ll see people recommending a more manual procedure for providing offset mappings. When implementing originalToTransformed, you’d consider the current cursor offset in the original string, and think about where that would be in the transformed string.

A zero offset is to the left of where the first character would appear — the resting position of an empty TextField.

An offset with the value 1 would be to the right of the first character, and so on.

This is pretty straightforward for something like a zip code or a credit card. Let’s walk through an example real quick. Feel free to skip this if you already get the idea.

Eager vs Lazy

I noticed there seem to be two approaches to formatting these strings — what I’m calling “eager” and “lazy”, for lack of better terms. I just made these up. For example, let’s look at a zip code.

Basic zip codes are 5 digits, but sometimes forms give you the option, or even require, a ZIP+4 format. ZIP+4 has 5 digits, a hyphen, and then 4 digits: xxxxx-xxxx.

Eager vs Lazy refers to when the separators get inserted.

Note: | refers to the cursor

Eager

What I’m calling “eager” here, in the case of a zip code, means that the hyphen would appear when the 5th digit is entered: 1234| -> 12345-|

Lazy

Conversely, what I’m calling “lazy” refers to inserting the hyphen when the 6th character is entered: 12345| -> 12345-6|

Which Should I Use?

Honestly, it depends, and it varies. If you’ve got a formatter you don’t have any control over, like google’s phone number formatter, you’re kind of beholden to it. If you’ve got control over the formatter, you can dictate which strategy you use. For our zip example, if ZIP+4 is required, maybe you eagerly show the hyphen. If it’s optional, maybe you only show it if the user starts to enter it.

If you mix strategies between formatters and offset mappings, you might get misplaced cursors, or, in some cases, outright crash.

Now that that’s out of the way, let’s consider something more complicated than a zip code

Credit Card — Lazy Formatter, Lazy Offsets

Let’s see how we might visually transform a credit card number as it’s entered. We’ll use a lazy formatter, and lazy offsets for this example. Unlike the zip example, we have more than one separator that we need to respect. First, our simple formatter:

object LazyCreditCardFormatter : Formatter {
    override fun format(input: CharSequence): CharSequence =
        input
            .chunked(4)
            .joinToString(" ")
}

Pretty simple and lazy. Now we need to think about our OffsetMapping

Original To Transformed

We’re handed the original cursor offset, and we’re required to calculate what the offset would be for the transformed string. You’ll notice I’ve used concrete ranges here rather than try to construct them in an abstract manner by making greater-than / less-than statements.

override fun originalToTransformed(offset: Int): Int =
    when (offset) {
        in (1..4) -> offset
        in (5..8) -> offset + 1
        in (9..12) -> offset + 2
        in (13..16) -> offset + 3
        else -> offset
    }

If we’re in the first range, there have been no separators inserted into the transformed string, so nothing changes with our offsets.

If we’re in the second range, that means a 5th character has been input. The string has been formatted to have a space in between the 4th and 5th characters. This is the point where your formatter and offset mapping should follow the same eager or lazy strategy. If the cursor were in the original string, it would be sitting after the 5th character. Since there’s an extra space that has been inserted, we need to move it to after the 6th character, so offset + 1, and so on for each range bucket.

Transformed to Original

This one breaks my brain sometimes, for whatever reason. In this example, we’re going in the opposite direction for offset transformations. Our range buckets are slightly different, depending on how we want cursor placement to behave when a user highlights text. You might have to play around with this a bit for each specific case.

override fun transformedToOriginal(offset: Int): Int =
        when (offset) {
            in (1..4) -> offset
            in (5..9) -> offset - 1
            in (11..14) -> offset - 2
            in (15..19) -> offset - 3
            else -> offset
        }

That doesn’t seem too bad. Our when statement has grown a bit, but that’s to be expected with more separators in the mix, right? Worst case, you probably sink an hour or two into it, trying to get it to behave properly. You might need to tinker and tweak it a bit for each different use case.

But what happens if you have to do something that has more dynamic formatting, like a phone number?

Formatting Phone Numbers

No way do I want to figure out all the possible offset mappings for phone numbers. The format can change dramatically as you type, not to mention the various formats for international calling. That seems like a nightmare. Let’s figure out a more dynamic solution.

originalToTransformed

In originalToTransformed, we were doing some addition depending on which range bucket the original offset fell within. A few ideas spring to mind, but they’re quickly proven naive under test.

Do we count how many separators are in the formatted string, then add that to our original offset? That ends up breaking cursor traversal through the full string. If you have 3 separators, you’ll only be able to move back to the third offset.

What about something like this?

override fun originalToTransformed(offset: Int): Int {
    if (offset == 0) {
        return 0
    }

    return formatted
        .substring(0, offset)
        .count { isSeparator(it) } + offset
}

That won’t work either. The cursor placement falls behind after the first separator is inserted. If we substring to offset, we fall behind after the first separator, and have weird cursor placement throughout.

I won’t belabor the point. Just know that I tried a lot of stuff. Feel a little bad for me, okay? I’m working with a tiny brain, here.

And Then It Clicked

What we really want to know is where to place the cursor in the transformed string, when given a position in the original string, right?

Let’s consider the unformatted string 5551234, and the formatted string 555–1234.

We know that each index in the string is one less than the offset to the right of that character. If we look at the formatted string, we know every index of every character. If we know that, we can derive every offset for every character, can’t we? It’s just a matter of filtering for the indices we care about, and turning the indices into offsets.

If we turned 555–1234 into a list of indices for all non-separator characters, we’d end up with [0,1,2,4,5,6,7]. Notice the index for the hyphen is missing.

If we added one to every one of those indices, we’d end up with offsets: [1,2,3,5,6,7,8]. If we run with this, though, we can never move the cursor to the left of the first character, every offset will be off by one, and we’ll probably crash on empty strings. That’s easy enough to fix — just prepend a 0 at the start, and we solve all those issues: [0,1,2,3,5,6,7,8].

Now we always know every transformed offset. Since we filtered out all the separators, we can access using the original offset without going out of bounds.

Let’s try it. If we’re given an original offset of 4, we know would place the cursor here: 555-1|234 in the transformed string. If we access our list of transformed offsets at index 4, we get 5. The 5th offset in the transformed string will be to the right of the 1, just like we want.

Here’s the relevant implementation:

override fun originalToTransformed(offset: Int): Int {
    val transformedOffsets = formatted
        .mapIndexedNotNull { index, c ->
            index
                .takeIf { PhoneNumberUtils.isNonSeparator(c) }
                // convert index to an offset
                ?.plus(1)
        }
        // We want to support an offset of 0 and shift everything to the right,
        // so we prepend that index by default
        .let { offsetList ->
            listOf(0) + offsetList
        }

    return transformedOffsets[offset]
}

OUR VIDEO RECOMMENDATION

, ,

Migrating to Jetpack Compose – an interop love story

Most of you are familiar with Jetpack Compose and its benefits. If you’re able to start anew and create a Compose-only app, you’re on the right track. But this talk might not be for you…
Watch Video

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engineer for Jetpack Compose
Google

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engin ...
Google

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engineer f ...
Google

Jobs

No results found.

transformedToOriginal

Let’s look at another example:

(555) 123-4567

If we’re given a transformed offset of 11, which is to the right of 4, we can look at our original string and see that it would correspond to an offset of 7. The key here is to notice the pattern. The difference between the values of the two offsets are the same as the number of separators that precede the transformed offset.

Once we notice that, things get pretty simple:

override fun transformedToOriginal(offset: Int): Int =
    formatted
        // This creates a list of all separator offsets
        .mapIndexedNotNull { index, c ->
            index.takeIf { !PhoneNumberUtils.isNonSeparator(c) }
        }
        // We want to count how many separators precede the transformed offset
        .count { separatorIndex ->
            separatorIndex < offset
        }
        // We find the original offset by subtracting the number of separators
        .let { separatorCount ->
            offset - separatorCount
        }

There we go, that’s much better than having to write out a super tedious when statement. But looking at this, doesn’t this solution seem fairly generic already?

One Size Fits All

The only thing here that pertains to phone numbers is PhoneNumberUtils.isNonSeparator(c). With that in mind, we could probably make a generic implementation fairly easily:

abstract class GenericSeparatorVisualTransformation : VisualTransformation {

    abstract fun transform(input: CharSequence): CharSequence

    abstract fun isSeparator(char: Char): Boolean

    override fun filter(text: AnnotatedString): TransformedText {

        val formatted = transform(text)

        return TransformedText(
            text = AnnotatedString(text = formatted.toString()),
            object : OffsetMapping {
                override fun originalToTransformed(offset: Int): Int {
                    val transformedOffsets = formatted
                        .mapIndexedNotNull { index, c ->
                            index
                                .takeIf { !isSeparator(c) }
                                // convert index to an offset
                                ?.plus(1)
                        }
                        // We want to support an offset of 0 and shift everything to the right,
                        // so we prepend that index by default
                        .let { offsetList ->
                            listOf(0) + offsetList
                        }

                    return transformedOffsets[offset]
                }

                override fun transformedToOriginal(offset: Int): Int =
                    formatted
                        // This creates a list of all separator offsets
                        .mapIndexedNotNull { index, c ->
                            index.takeIf { isSeparator(c) }
                        }
                        // We want to count how many separators precede the transformed offset
                        .count { separatorIndex ->
                            separatorIndex < offset
                        }
                        // We find the original offset by subtracting the number of separators
                        .let { separatorCount ->
                            offset - separatorCount
                        }
            }
        )
    }
}

I ended up using this to format phone numbers. As a bonus, here’s a simple phone number formatter I whipped up that supports international formatting. Press 0 as the first input to prepend a +, which triggers international formatting.

class SimplePhoneNumberFormatter(defaultCountry: String = Locale.getDefault().country) {

    private val formatter = PhoneNumberUtil.getInstance().getAsYouTypeFormatter(defaultCountry)

    private fun String.replaceIndexIf(
        index: Int,
        value: Char,
        predicate: (Char) -> Boolean
    ): String =
        if (predicate(get(index))) {
            toCharArray()
                .apply { set(index, value) }
                .let(::String)
        } else this

    fun format(number: String): String =
        number
            .takeIf { it.isNotBlank() }
            ?.replaceIndexIf(0, '+') { c ->
                c == '0'
            }
            ?.filter { it.isDigit() || it == '+' }
            ?.let { sanitized ->
                formatter.clear()
                var formatted = ""
                sanitized.forEach {
                    formatted = formatter.inputDigit(it)
                }
                formatted
            } ?: number
}

Use these two classes in conjunction like so:

class PhoneNumberVisualTransformation(
    defaultLocale: Locale = Locale.getDefault(),
) : GenericSeparatorVisualTransformation() {

    private val phoneNumberFormatter = SimplePhoneNumberFormatter(defaultLocale.country)

    override fun transform(input: CharSequence): CharSequence =
        phoneNumberFormatter.format(input.toString())

    override fun isSeparator(char: Char): Boolean = !PhoneNumberUtils.isNonSeparator(char)
}

And remember to limit the inputs from your text field, or you’re gonna have a bad time:

TextField(
    value = phone,
    onValueChange = { value -> phone = value.takeWhile { it.isDigit() } },
    visualTransformation = remember { PhoneNumberVisualTransformation() }
)

Hope you learned something. I know I did. And I never want to have to learn it again, so I wrote this article as a dev journal. Phew.

P.S.

If you’re really into the whole eager formatting thing, and need this approach to support eager offsets, you could do something like:

private fun getOffsetFactor(input: CharSequence, index: Int): Int {
    if (!useEagerOffsets) {
        return 1
    }
    val nextIndex = index + 1
    val hasNext = nextIndex <= input.lastIndex
    val nextIsSeparator = hasNext && isSeparator(input[nextIndex])
    return if (nextIsSeparator) 2 else 1
}

pass a flag into the generic offset mapping:

abstract class GenericSeparatorVisualTransformation(private val useEagerOffsets: Boolean = false) :
    VisualTransformation

and do something like:

index
    .takeIf { !isSeparator(c) }
    // convert index to an offset
    ?.plus(getOffsetFactor(formatted, index))

But I kind of hate it, and I didn’t test it a lot, so your mileage may vary.

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

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu