Algumas vezes, erros são repetidos como padrões por diversas pessoas e replicados em diversos cenários diferentes. O desenvolvimento para Android nativo não é muito diferente.
Recentemente, notei um desses erros que são cometidos com frequência, mas tratados como algo normal e tendo seus efeitos colaterais tratados com soluções um tanto improvisadas.
TextWatcher, implementação comum e o problema
No geral, as implementações de TextWatcher são utilizadas para fazer validações no input ou aplicar máscaras de texto. Este segundo sendo o alvo desse artigo. Uma implementação comum que observei em diversos projetos tinha uma sequência semelhante no processo de aplicação das máscaras:
- Remover o TextWatcher na chamada do onTextChanged oafterTextChange
- Aplicar máscara e atribuir o valor ao EditText novamente com setText
- Ajustar a posição do cursor do input
- Atribuir novamente o listener
Abaixo exemplifico um código gerado por IA, reforçando que diversos códigos utilizados como fonte implementam da forma incorreta:
class CpfTextWatcher(
private val editText: EditText
) : TextWatcher {
private val mask = CpfMask()
override fun beforeTextChanged(
s: CharSequence?,
start: Int,
count: Int,
after: Int,
) = Unit
override fun onTextChanged(
s: CharSequence?,
start: Int,
before: Int,
count: Int,
) = Unit
override fun afterTextChanged(s: Editable?) {
editText.removeTextChangedListener(this)
val current = s.toString()
val unmasked = current.replace(Regex("[^\\d]"), "")
val formatted = mask.applyMask(unmasked)
editText.setText(formatted)
editText.setSelection(formatted.length)
editText.addTextChangedListener(this)
}
}
Qual o problema exatamente?
Sem mistérios, o maior problema dessa implementação é o EditText.setText. Quando utilizamos o EditText.setText, o estado do input e do teclado é completamente reiniciado. O efeito desse reinício é o cursor voltando para o início, e o teclado caso esteja na aba de caracteres especiais seja forçado a voltar para o teclado padrão com letras.
É por conta do EditText.setText que sempre é necessário reposicionar o cursor manualmente, e isso é um problema!
Qual a forma correta de aplicar a máscara?
O correto é utilizar o objeto Editable do método afterTextChanged. É um objeto criado justamente para aplicar as mudanças necessárias e ideal para aplicar o texto mascarado. O código corrigido seria algo dessa forma:
class CpfTextWatcher : TextWatcher {
...
override fun afterTextChanged(s: Editable?) {
s?.let { editable ->
val current = editable.toString()
val unmasked = current.replace(Regex("[^\\d]"), "")
val formatted = mask.applyMask(unmasked)
editable.replace(0, current.length, formatted)
}
}
}
Dessa forma, por não utilizar o EditText.setText os estados não são reiniciados, e não temos o problema de reposicionamento do cursor do input, e a experiência do usuário fica muito mais fluida e agradável.
Por que o EditText.setText reinicia o teclado?
Este ponto me inquietou enquanto eu não tinha a resposta. Então verifiquei os códigos das implementações básicas da View no código fonte do Android, no Android Code Search.
O EditText herda de TextView, e utiliza o TextView.setText. Analisando o código e navegando algumas funções adentro, encontramos esse método que faz a atribuição do novo texto:

Descendo um pouco nesse mesmo método, encontramos a linha 7378 que utiliza o Editor. O Editor é uma classe que faz o gerenciamento visual do que é mostrado, como seleção de texto, cursor, autocomplete e outras coisas.

O método Editor.scheduleRestartInputForSetText basicamente faz a atribuição como true da variável mHasPendingRestartInputForSetText. Essa variável é utilizada mais tarde no método Editor.maybeFireScheduledRestartInputForSetText, que executa o invalidate no InputMethodManager, o gerenciador do teclado virtual, o que ocasiona no reinício do teclado.

Então, o método Editor.maybeFireScheduledRestartInputForSetText é chamado logo abaixo no mesmo método TextView.setText, ocasionando o reinício do estado do teclado.

Finalização
Espero que tenha sido interessante entender melhor como ajustar a implementação do TextWatcher e a razão do problema causado pela implementação incorreta. Estou aberto a correções/sugestões pelos comentários ou por redes sociais!