O CNPJ (Cadastro Nacional de Pessoa Jurídica) está presente na vida de muitas empresas e frequentemente precisamos receber este dado em um aplicativo por meio de um campo de texto. Diferentemente do desenvolvimento de telas tradicional que permitia a entrada de dados por meio do EditText, em Compose precisamos utilizar o TextField (na verdade, existe também o BasicTextField, que provê mais opções de customização visual) e isso exige modificações na forma de lidar com máscaras de texto.
O CNPJ é um código numérico de 14 dígitos que segue o formato definido no Anexo XV da Instrução Normativa nº 2119 da Receita Federal:

Para permitir a implementação de máscara de textos, o composable TextField pode receber como argumento um a implementação da interface VisualTransformation, que possui somente um método, filter, que recebe o texto original e retorna um TransformedText. Segue um excerto da definição da interface:
@Immutable
fun interface VisualTransformation {
/*
Params:
text - The original text
Returns:
the pair of filtered text and offset translator.
*/
fun filter(text: AnnotatedString): TransformedText
companion object {
/**
* A special visual transformation object indicating that no transformation is applied.
*/
@Stable
val None: VisualTransformation = VisualTransformation { text ->
TransformedText(text, OffsetMapping.Identity)
}
}
}
O retorno deste método deve conter tanto uma string anotada quanto um OffsetMapping. O OffsetMapping é importante para fazer com que o cursor saiba se mover nos índices em que são adicionados os caracteres extras da máscara.
Inicialmente, iremos criar um campo de texto e passaremos a implementação CnpjVisualTransformation da interface VisualTransformation para ele. Neste ponto, esta interface não faz nada, apenas retorna uma string exatamente igual à que foi inserida pelo usuário. Para fazer isso, utilizamos um OffsetMapping.Identity. Este OffsetMapping não realiza nenhum mapeamento.
@Composable
fun ExampleTextField(modifier: Modifier = Modifier) {
var input by remember { mutableStateOf("") }
TextField(value = input, onValueChange = {
input = it
}, visualTransformation = CnpjVisualTransformation())
}
class CnpjVisualTransformation: VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
val transformedCnpj = buildString {
for(i in text.text.indices) {
append(text[i])
}
}
return TransformedText(AnnotatedString(transformedCnpj), OffsetMapping.Identity)
}
}
Definição do filtro
Agora, iremos começar a fazer a modificação na string original, recebida pelo método filter. Para isso, verificamos os índices da string e adicionamos os caracteres conforme o padrão do CNPJ.
override fun filter(text: AnnotatedString): TransformedText {
val transformedCnpj = buildString {
for(i in text.text.indices) {
append(text[i])
if(i == 1 || i == 4) append(".")
if(i == 7) append("/")
if(i == 11) append("-")
}
}
return TransformedText(AnnotatedString(transformedCnpj), OffsetMapping.Identity)
}
Com o uso do método buildString da linguagem Kotlin, torna-se fácil adicionar os marcadores, basta adicionar o caractere original, e, a depender do índice, adicionar também o marcador pretendido. Para melhor entendimento a respeito dos índices utilizados, podemos utilizar a tabela de indices de um CNPJ de exemplo (CNPJ do Banco do Brasil)>
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | ||||
0 | 0 | . | 0 | 0 | 0 | . | 0 | 0 | 0 | / | 0 | 0 | 0 | 1 | – | 9 | 1 |
Agora que alteremos a string que será apresentada ao usuário, podemos verificar que somente utilizar o OffsetMapping.Identity não funcionará e obteremos o seguinte crash em tempo de execução do aplicativo:
java.lang.IllegalStateException: OffsetMapping.transformedToOriginal returned invalid mapping: 3 -> 3 is not in range of original text [0, 2]
Definição do OffsetMapping
A interface OffsetMapping requer a implementação de dois métodos: originalToTransformed e transformedToOriginal. Estes métodos servem para guiar o posicionamento do cursor ao longo do texto que está sendo escrito pelo usuário, uma vez que estamos alterando os índices originais ao inserir os marcadores do CNPJ. Segue a definição da interface na base de código do Android:
/**
* Provides bidirectional offset mapping between original and transformed text.
*/
interface OffsetMapping {
/**
* 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.
*
* @param offset offset in original text.
* @return offset in transformed text
*
* @see VisualTransformation
*/
fun originalToTransformed(offset: Int): Int
/**
* Convert offset in transformed text into the offset in original text.
*
* This function must be a monotonically non-decreasing function. In other words, if a cursor
* advances in the transformed text, the cusrsor in the original text must advance or stay
* there.
*
* @param offset offset in transformed text
* @return offset in original text
*
* @see VisualTransformation
*/
fun transformedToOriginal(offset: Int): Int
}
Para encontrar definições de interfaces e classes do Android, recomendo o uso do Android Code Search: https://cs.android.com/
Tendo implementado a adição dos marcadores na string original, resta somente implementar os métodos que definem o posicionamento do cursor no campo de texto.
Implementando a função originalToTransformed
O método originalToTransformed mapeia o posicionamento do cursor na string original, para a sua posição após a inclusão dos marcadores na string pretendida. Para melhor compreender, podemos pensar que estamos, a depender do índice, adicionando um offset (deslocamento) no cursor do campo de texto.
override fun originalToTransformed(offset: Int): Int {
if (offset >= 12) return offset + 4
if (offset >= 8) return offset + 3
if (offset >= 5) return offset + 2
if (offset >= 2) return offset + 1
else return offset
}
Utilizando a mesma tabela que utilizamos acima, verificamos que, a partir do índice 12 da string original, devemos “pular” 4 casas adicionais, pois já existem 4 marcadores a partir desta posição. A partir da posição 8, devemos pular 3 casas e assim por diante até chegarmos ao caso base de não termos de pular nenhuma casa, e neste caso apenas retornamos o offset original, que será zero (sem deslocamento).
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | ||||
0 | 0 | . | 0 | 0 | 0 | . | 0 | 0 | 0 | / | 0 | 0 | 0 | 1 | – | 9 | 1 |
Implementando a função transformedToOriginal
Diferentemente dos métodos anteriores, em que trabalhamos somente com os índices da string original, sem contar com os índices dos marcadores, o método transformedToOriginal requer que contemos com todos os índices (repare que as células da tabela que estavam em branco agora estão preenchidas).
override fun transformedToOriginal(offset: Int): Int {
if (offset >= 16) return offset - 4
if (offset >= 11) return offset - 3
if (offset >= 7) return offset - 2
if (offset >= 3) return offset - 1
else return offset
}
Se antes somávamos o deslocamento para contar com os marcadores, neste método nós subtraímos o deslocamento para não contar com eles. Observe que, a partir do índice 16 da string com os marcadores, já contamos com 4 marcadores, e portanto precisamos subtraí-los. De forma semelhante, a partir do índice 11, contamos com 3 marcadores previamente adicionados, e agora precisamos subtraí-los para obter o índice da string original e assim por diante.
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
0 | 0 | . | 0 | 0 | 0 | . | 0 | 0 | 0 | / | 0 | 0 | 0 | 1 | – | 9 | 1 |
Código completo
@Composable
fun ExampleTextField(modifier: Modifier = Modifier) {
var input by remember { mutableStateOf("") }
TextField(
modifier = modifier,
value = input,
onValueChange = {
if (it.length <= 14) input = it
},
visualTransformation = CnpjVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
}
class CnpjVisualTransformation : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
val transformedCnpj = buildString {
for (i in text.text.indices) {
append(text[i])
if (i == 1 || i == 4) append(".")
if (i == 7) append("/")
if (i == 11) append("-")
}
}
return TransformedText(AnnotatedString(transformedCnpj), cnpjMapping)
}
val cnpjMapping = object : OffsetMapping {
override fun originalToTransformed(offset: Int): Int {
if (offset >= 12) return offset + 4
if (offset >= 8) return offset + 3
if (offset >= 5) return offset + 2
if (offset >= 2) return offset + 1
else return offset
}
override fun transformedToOriginal(offset: Int): Int {
if (offset >= 16) return offset - 4
if (offset >= 11) return offset - 3
if (offset >= 7) return offset - 2
if (offset >= 3) return offset - 1
else return offset
}
}
Máscara em funcionamento

Próximos passos
- Consegue ver como criar uma transformação visual genérica, que receba como parâmetro a máscara a ser aplicada no formato “###.###.###”, por exemplo, para poder aplicá-la em um campo de texto qualquer?