Capítulo 08: Formulários, Validação Reativa, Teclados Virtuais e Focus Management

Especialização em Mobile Nativo com Android • Kotlin 2.0 & Jetpack Compose


🗺️ Mapa Conceitual do Fluxo de Entrada de Dados e Foco

flowchart TD
    INPUT["Entrada do Usuário no Teclado Virtual (IME)"] --> TF["OutlinedTextField (value / onValueChange)"]

    subgraph VALIDATION["Motor de Validação Reativa"]
        TF --> RULE["Regras de Negócio (NotBlank, EmailRegex, CPF, Senha Forte)"]
        RULE -->|"Válido"| VALID["isError = false | Botão Habilitado"]
        RULE -->|"Inválido"| ERROR["isError = true | supportingText com Erro Amigável"]
    end

    subgraph FOCUS["Gerenciador de Foco (FocusManager & FocusRequester)"]
        ACTION["Ação do Teclado: ImeAction.Next"] --> NEXT_FOCUS["focusManager.moveFocus(FocusDirection.Down)"]
        DONE["Ação do Teclado: ImeAction.Done"] --> CLEAR_FOCUS["focusManager.clearFocus() | Fecha Teclado"]
    end

    TF --> ACTION
    TF --> DONE

    style INPUT fill:#e1f5fe,stroke:#03a9f4,stroke-width:2px
    style TF fill:#ede7f6,stroke:#7e57c2,stroke-width:2px
    style VALID fill:#e8f5e9,stroke:#4caf50,stroke-width:2px
    style ERROR fill:#fce4ec,stroke:#e91e63,stroke-width:2px
    style NEXT_FOCUS fill:#fff3e0,stroke:#ff9800,stroke-width:2px

🏛️ 1. A Engenharia de Formulários Declarativos no Compose

Capturar entradas do usuário de forma confiável e com alta usabilidade móvel exige o controle simultâneo de quatro dimensões: controle do estado do texto, integração com o Teclado do Sistema Operacional (IME - Input Method Editor), gerenciamento de foco entre múltiplos campos e feedback de validação em tempo real:

  1. O Componente OutlinedTextField: No Compose, campos de texto seguem o princípio de componente controlado. O campo não armazena seu próprio texto internamente; ele recebe um valor (value: String) e emite cada tecla pressionada pelo usuário através do evento (onValueChange: (String) -> Unit). Isso permite aplicar máscaras automáticas (como CPF ou CEP) ou interceptar caracteres inválidos antes que cheguem à tela.
  2. Controle Fino de Teclado e Foco: Através de KeyboardOptions(keyboardType = ..., imeAction = ...), configuramos o sistema operacional para exibir o teclado mais adequado (numérico, e-mail com @, ou senha). Com o LocalFocusManager.current e instâncias de FocusRequester, navegamos o cursor automaticamente de um campo para o próximo quando o usuário pressiona “Avançar” no teclado.
  3. Acessibilidade e supportingText: O Material 3 padronizou a exibição de erros e contadores através do slot supportingText. Isso garante que leitores de tela como o TalkBack anunciem os erros com prioridade semântica para usuários com deficiência visual.

📊 2. Comparativo de Tipos de TextFields no Jetpack Compose

Componente Nível de Customização Suporte a Material Design Uso Ideal em Arquitetura
BasicTextField Máximo (Canvas sem nenhuma borda ou padding) Nenhum (exige desenhar tudo manualmente) Criação de Design Systems proprietários ou inputs tipo código PIN
TextField Médio (Fundo preenchido com linha inferior de contraste) Material 2 e Material 3 nativo Formulários compactos onde linhas preenchidas se destacam
OutlinedTextField Alto (Borda perimetral, label flutuante e cantos arredondados) Padrão Ouro no Material Design 3 Telas corporativas de login, checkout e cadastros formais

💻 3. Laboratório Prático: Formulário Completo com Máscara e Focus Handling

O exemplo a seguir demonstra um formulário de login e autenticação corporativa integrando validação reativa, alternância de visibilidade de senha e navegação fluida de foco:

package br.com.portal.mobile.android.cap08

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp

@Composable
fun FormularioAutenticacao(
    onAutenticar: (email: String, chave: String) -> Unit,
    modifier: Modifier = Modifier
) {
    val focusManager = LocalFocusManager.current

    var email by rememberSaveable { mutableStateOf("") }
    var senha by rememberSaveable { mutableStateOf("") }
    var senhaVisivel by remember { mutableStateOf(false) }

    // Regras de validação em tempo de execução
    val emailValido = remember(email) {
        email.contains("@") && email.contains(".") && email.length >= 6
    }
    val senhaValida = remember(senha) { senha.length >= 8 }
    val formularioValido = emailValido && senhaValida

    Column(
        modifier = modifier
            .fillMaxWidth()
            .padding(20.dp),
        verticalArrangement = Arrangement.spacedBy(14.dp)
    ) {
        Text("Acesso Corporativo Seguro", style = MaterialTheme.typography.titleLarge)

        // Campo 1: E-mail Corporativo
        OutlinedTextField(
            value = email,
            onValueChange = { email = it.trim() },
            label = { Text("E-mail Profissional") },
            leadingIcon = { Icon(Icons.Default.Email, contentDescription = null) },
            isError = email.isNotBlank() && !emailValido,
            supportingText = {
                if (email.isNotBlank() && !emailValido) {
                    Text("Informe um formato de e-mail corporativo válido.")
                }
            },
            keyboardOptions = KeyboardOptions(
                keyboardType = KeyboardType.Email,
                imeAction = ImeAction.Next
            ),
            keyboardActions = KeyboardActions(
                onNext = { focusManager.moveFocus(FocusDirection.Down) }
            ),
            singleLine = true,
            modifier = Modifier.fillMaxWidth()
        )

        // Campo 2: Senha com Toggle de Visualização
        OutlinedTextField(
            value = senha,
            onValueChange = { senha = it },
            label = { Text("Chave de Segurança") },
            leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) },
            trailingIcon = {
                IconButton(onClick = { senhaVisivel = !senhaVisivel }) {
                    Icon(
                        imageVector = if (senhaVisivel) Icons.Default.VisibilityOff else Icons.Default.Visibility,
                        contentDescription = "Alternar Visibilidade"
                    )
                }
            },
            visualTransformation = if (senhaVisivel) VisualTransformation.None else PasswordVisualTransformation(),
            isError = senha.isNotBlank() && !senhaValida,
            supportingText = {
                if (senha.isNotBlank() && !senhaValida) {
                    Text("A senha deve conter no mínimo 8 caracteres.")
                }
            },
            keyboardOptions = KeyboardOptions(
                keyboardType = KeyboardType.Password,
                imeAction = ImeAction.Done
            ),
            keyboardActions = KeyboardActions(
                onDone = {
                    focusManager.clearFocus()
                    if (formularioValido) onAutenticar(email, senha)
                }
            ),
            singleLine = true,
            modifier = Modifier.fillMaxWidth()
        )

        Spacer(modifier = Modifier.height(6.dp))

        Button(
            onClick = {
                focusManager.clearFocus()
                onAutenticar(email, senha)
            },
            enabled = formularioValido,
            modifier = Modifier.fillMaxWidth().height(48.dp)
        ) {
            Text("Entrar na Plataforma")
        }
    }
}

⚠️ 4. Armadilhas Comuns em Inputs e Teclados Virtuais

  1. Esquecer de Fechar o Teclado com focusManager.clearFocus(): Ao submeter o formulário ou clicar no botão primário, sempre limpe o foco para recolher o teclado virtual. Deixar o teclado aberto sobrepondo mensagens de sucesso prejudica a experiência de uso.
  2. Não Configurar imePadding() no Contêiner: Em telas de formulário longas, certifique-se de aplicar Modifier.imePadding() no contêiner ou no Scaffold. Sem esse modificador, o teclado virtual sobrepõe os campos inferiores sem empurrar a tela para cima.
  3. Armazenar Senhas em Texto Puro sem PasswordVisualTransformation: Nunca renderize senhas com máscaras manuais de asteriscos aplicadas diretamente na string de estado. Use o transformador semântico PasswordVisualTransformation() para garantir conformidade com os serviços de preenchimento automático do Android (Autofill API).