7

I have a TextField in which there cannot be more than 10 characters, and the user is required to enter date in the format "mm/dd/yyyy". Whenever user types first 2 characters I append "/", when the user types next 2 characters I append "/" again.

I did the following to achieve this:

            var maxCharDate = 10

            TextField(
                value = query2,
                onValueChange = {
                    if (it.text.length <= maxCharDate) {
                        if (it.text.length == 2 || it.text.length == 5)
                            query2 = TextFieldValue(it.text + "/", selection = TextRange(it.text.length+1))
                        else
                            query2 = it
                    }
                    emailErrorVisible.value = false
                },
                label = {
                    Text(
                        "Date of Birth (mm/dd/yyyy)",
                        color = colorResource(id = R.color.bright_green),
                        fontFamily = FontFamily(Font(R.font.poppins_regular)),
                        fontSize = with(LocalDensity.current) { dimensionResource(id = R.dimen._12ssp).toSp() })
                },
                  .
                  .
                  .

It's working except that the appended "/" doesn't get deleted on pressing backspace, while other characters do get deleted.

How do I make it such that "/" is deleted too on pressing backspace?

Gabriele Mariotti
  • 320,139
  • 94
  • 887
  • 841
Sparsh Dutta
  • 2,450
  • 4
  • 27
  • 54

5 Answers5

12

You can do something different using the onValueChange to define a max number of characters and using visualTransformation to display your favorite format without changing the value in TextField.

val maxChar = 8
TextField(
    singleLine = true,
    value = text,
    onValueChange = {
        if (it.length <= maxChar) text = it
    },
    visualTransformation = DateTransformation()
)

where:

class DateTransformation() : VisualTransformation {
    override fun filter(text: AnnotatedString): TransformedText {
        return dateFilter(text)
    }
}

fun dateFilter(text: AnnotatedString): TransformedText {

    val trimmed = if (text.text.length >= 8) text.text.substring(0..7) else text.text
    var out = ""
    for (i in trimmed.indices) {
        out += trimmed[i]
        if (i % 2 == 1 && i < 4) out += "/"
    }

    val numberOffsetTranslator = object : OffsetMapping {
        override fun originalToTransformed(offset: Int): Int {
            if (offset <= 1) return offset
            if (offset <= 3) return offset +1
            if (offset <= 8) return offset +2
            return 10
        }

        override fun transformedToOriginal(offset: Int): Int {
            if (offset <=2) return offset
            if (offset <=5) return offset -1
            if (offset <=10) return offset -2
            return 8
        }
    }

    return TransformedText(AnnotatedString(out), numberOffsetTranslator)
}

enter image description here

Gabriele Mariotti
  • 320,139
  • 94
  • 887
  • 841
4

enter image description here

Implementation of VisualTranformation that accepts any type of mask for Jetpack Compose TextField:

class MaskVisualTransformation(private val mask: String) : VisualTransformation {

    private val specialSymbolsIndices = mask.indices.filter { mask[it] != '#' }

    override fun filter(text: AnnotatedString): TransformedText {
        var out = ""
        var maskIndex = 0
        text.forEach { char ->
            while (specialSymbolsIndices.contains(maskIndex)) {
                out += mask[maskIndex]
                maskIndex++
            }
            out += char
            maskIndex++
        }
        return TransformedText(AnnotatedString(out), offsetTranslator())
    }

    private fun offsetTranslator() = object : OffsetMapping {
        override fun originalToTransformed(offset: Int): Int {
            val offsetValue = offset.absoluteValue
            if (offsetValue == 0) return 0
            var numberOfHashtags = 0
            val masked = mask.takeWhile {
                if (it == '#') numberOfHashtags++
                numberOfHashtags < offsetValue
            }
            return masked.length + 1
        }

        override fun transformedToOriginal(offset: Int): Int {
            return mask.take(offset.absoluteValue).count { it == '#' }
        }
    }
}

How to use it:

@Composable
fun DateTextField() {
    var date by remember { mutableStateOf("") }
    TextField(
        value = date,
        onValueChange = {
            if (it.length <= DATE_LENGTH) {
                date = it
            }
        },
        visualTransformation = MaskVisualTransformation(DATE_MASK)
    )
}

object DateDefaults {
    const val DATE_MASK = "##/##/####"
    const val DATE_LENGTH = 8 // Equals to "##/##/####".count { it == '#' }
}
Thiago Souza
  • 1,171
  • 8
  • 16
  • 1
    Congrats, and thank you. I wrote [this](https://stackoverflow.com/a/60327971/3808178) few years ago. Yours is the compose version. Thanks again – Maxime Claude Mar 31 '23 at 13:59
2

The / is being deleted but as soon as you delete, the length of the text becomes 2 or 5. So it checks the condition,

if (it.text.length == 2 || it.text.length == 5)

Since the condition is true now, the / appends again into the text. So it seems like it is not at all being deleted.

One way to solve this is by storing the previous text length and checking if the text length now is greater than the previous text length.

To achieve this, declare a variable below maxCharDate as

var previousTextLength = 0

And change the nested if condition to,

if ((it.text.length == 2 || it.text.length == 5) && it.text.length > previousTextLength)

And at last update the previousTextLength variable. Below the emailErrorVisible.value = false add

previousTextLength = it.text.length;
user13608097
  • 66
  • 2
  • 9
2

I would suggest not only a date mask, but a simpler and generic solution for inputs masking.

A general formatter interface in order to implement any kind of mask.

interface MaskFormatter {
    fun format(textToFormat: String): String
}

Implement our own formatters.

object DateFormatter : MaskFormatter {
    override fun format(textToFormat: String): String {
        TODO("Format '01212022' into '01/21/2022'")
    }
}

object CreditCardFormatter : MaskFormatter {
    override fun format(textToFormat: String): String {
        TODO("Format '1234567890123456' into '1234 5678 9012 3456'")
    }
}

And finally use this generic extension function for transforming your text field inputs and you won't need to care about the offsets at all.

internal fun MaskFormatter.toVisualTransformation(): VisualTransformation =
    VisualTransformation {
        val output = format(it.text)
        TransformedText(
            AnnotatedString(output),
            object : OffsetMapping {
                override fun originalToTransformed(offset: Int): Int = output.length
                override fun transformedToOriginal(offset: Int): Int = it.text.length
            }
        )
    }

Some example usages:

// Date Example
private const val MAX_DATE_LENGTH = 8

@Composable
fun DateTextField() {
    var date by remember { mutableStateOf("") }
    TextField(
        value = date,
        onValueChange = {
            if (it.matches("^\\d{0,$MAX_DATE_LENGTH}\$".toRegex())) {
                date = it
            }
        },
        visualTransformation = DateFormatter.toVisualTransformation()
    )
}


// Credit Card Example
private const val MAX_CREDIT_CARD_LENGTH = 16

@Composable
fun CreditCardTextField() {
    var creditCard by remember { mutableStateOf("") }
    TextField(
        value = creditCard,
        onValueChange = {
            if (it.matches("^\\d{0,$MAX_CREDIT_CARD_LENGTH}\$".toRegex())) {
                creditCard = it
            }
        },
        visualTransformation = CreditCardFormatter.toVisualTransformation()
    )
}
Geraldo Neto
  • 3,670
  • 1
  • 30
  • 33
epool
  • 6,710
  • 7
  • 38
  • 43
0

It is because you are checking for the length of the string. Whenever the length is two, you insert a slash. Hence the slash gets deleted, and re-inserted.

Why don't you just create three TextFields and insert Slashes as Texts in between. Such logic can be very hard to perfect. Keen users can use it to crash your app, and also devs can insert malicious stuff, and exploit this flaw because the handling logic can have loopholes as well, so... It is better in my opinion to just go with the simplest (and what I think is more elegant) way of constructing.

Richard Onslow Roper
  • 5,477
  • 2
  • 11
  • 42