Quando usar Form (e quando evitar)
Form é um contêiner do SwiftUI otimizado para telas de entrada de dados: ele organiza campos em linhas, aplica espaçamentos e estilos nativos do iOS, lida bem com rolagem e com o teclado, e funciona naturalmente com Section para agrupar informações. Use Form quando você precisa de uma tela “de configurações” ou “de cadastro/edição” com campos alinhados e comportamento consistente. Evite Form quando você precisa de um layout altamente customizado (por exemplo, cartões, grids complexos ou campos fora do padrão), pois ele impõe um estilo e uma hierarquia próprios.
Estruturando entradas com Form e Section
Um formulário bem estruturado costuma separar: (1) dados principais, (2) dados opcionais, (3) preferências/controles, (4) ações. Em SwiftUI, isso se traduz em múltiplas Section com cabeçalhos e rodapés para instruções e mensagens.
struct ProfileEditView: View { var body: some View { Form { Section("Dados") { // campos principais } Section("Preferências") { // toggles, pickers } Section { // ações (salvar/cancelar) } } }Dicas práticas de UX com Section:
- Use o rodapé (
footer) para instruções curtas (“Seu e-mail será usado para login”). - Evite seções com muitos campos sem separação: quebre em grupos menores para reduzir carga cognitiva.
- Coloque ações destrutivas (ex.: “Excluir conta”) em uma seção separada e visualmente distante.
Modelo de edição conectado ao estado do app
Em telas de edição, é comum trabalhar com uma cópia editável dos dados, e só aplicar no “estado do app” quando o usuário tocar em Salvar. Isso evita que mudanças parciais afetem outras telas antes da confirmação.
Abaixo, um exemplo completo com: (1) modelo de domínio (UserProfile), (2) store do app (AppStore), (3) view de edição que recebe o store e edita uma cópia local, (4) validação e bloqueio de envio.
- Ouça o áudio com a tela desligada
- Ganhe Certificado após a conclusão
- + de 5000 cursos para você explorar!
Baixar o aplicativo
1) Modelo e store
import SwiftUIimport Observation@Observablefinal class AppStore { var profile: UserProfile = .init( fullName: "", email: "", age: 18, newsletter: true, bio: "" ) func saveProfile(_ updated: UserProfile) { // Em um app real, aqui você chamaria um serviço, persistência etc. profile = updated }}struct UserProfile: Equatable { var fullName: String var email: String var age: Int var newsletter: Bool var bio: String}@Observable (Observation) permite que a UI reaja às mudanças do store. Se o seu projeto estiver usando outro padrão (por exemplo, ObservableObject), a ideia permanece: a tela de edição recebe uma referência ao estado do app e aplica alterações ao salvar.
2) View de edição com cópia local
struct ProfileEditView: View { @Environment(AppStore.self) private var store @State private var draft: UserProfile @State private var didTrySubmit = false @State private var isSaving = false init(profile: UserProfile) { _draft = State(initialValue: profile) } var body: some View { Form { Section("Dados") { // campos aqui } Section("Preferências") { // campos aqui } Section("Sobre você") { // campos aqui } Section { // ações aqui } } .navigationBarTitleDisplayMode(.inline) }}O draft representa o que o usuário está editando. Ao tocar em Salvar, você valida e então chama store.saveProfile(draft).
Validação com regras: obrigatório, e-mail e limites numéricos
Uma validação boa para UX tem três características: (1) regras claras, (2) mensagens específicas, (3) feedback no momento certo (nem cedo demais, nem tarde demais). Uma abordagem simples é: validar continuamente, mas só exibir erros após o usuário tentar salvar (ou após o campo perder foco).
Definindo erros e funções de validação
enum FieldError: Equatable { case required(String) case invalidEmail case outOfRange(min: Int, max: Int) var message: String { switch self { case .required(let field): return "O campo \(field) é obrigatório." case .invalidEmail: return "Digite um e-mail válido." case .outOfRange(let min, let max): return "Digite um valor entre \(min) e \(max)." } }}func isValidEmail(_ text: String) -> Bool { // Validação simples (boa para a maioria dos casos iniciais). // Em apps críticos, considere regras mais robustas. let pattern = #"^[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$"# return text.range(of: pattern, options: .regularExpression) != nil}Calculando erros por campo
Você pode derivar erros como propriedades computadas, mantendo a lógica centralizada e fácil de testar.
extension ProfileEditView { var nameError: FieldError? { draft.fullName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? .required("Nome") : nil } var emailError: FieldError? { let email = draft.email.trimmingCharacters(in: .whitespacesAndNewlines) if email.isEmpty { return .required("E-mail") } if !isValidEmail(email) { return .invalidEmail } return nil } var ageError: FieldError? { let min = 13 let max = 120 if draft.age < min || draft.age > max { return .outOfRange(min: min, max: max) } return nil } var canSubmit: Bool { nameError == nil && emailError == nil && ageError == nil && !isSaving }}canSubmit é a base para prevenção de envio: você desabilita o botão de salvar quando houver erros, e também evita executar a ação.
Mensagens de erro e prevenção de envio
Existem várias formas de mostrar erros em SwiftUI. Uma prática comum é exibir um texto de erro logo abaixo do campo, em vermelho, e opcionalmente um ícone/realce. O importante é manter consistência: sempre no mesmo lugar e com linguagem objetiva.
Componente reutilizável para erro
struct FieldErrorText: View { let error: FieldError? var body: some View { if let error { Text(error.message) .font(.footnote) .foregroundStyle(.red) } }}Aplicando no formulário
extension ProfileEditView { @ViewBuilder var dataSection: some View { Section("Dados") { TextField("Nome completo", text: $draft.fullName) .textContentType(.name) .autocorrectionDisabled(false) if didTrySubmit { FieldErrorText(error: nameError) } TextField("E-mail", text: $draft.email) .keyboardType(.emailAddress) .textInputAutocapitalization(.never) .autocorrectionDisabled() .textContentType(.emailAddress) if didTrySubmit { FieldErrorText(error: emailError) } Stepper(value: $draft.age, in: 0...130) { HStack { Text("Idade") Spacer() Text("\(draft.age)") .foregroundStyle(.secondary) } } if didTrySubmit { FieldErrorText(error: ageError) } } }}Note que o Stepper já limita o valor, mas ainda assim é útil validar (por exemplo, idade mínima) e mostrar uma mensagem clara.
Botão Salvar com bloqueio e feedback
extension ProfileEditView { @ViewBuilder var actionsSection: some View { Section { Button { submit() } label: { HStack { if isSaving { ProgressView() } Text(isSaving ? "Salvando..." : "Salvar") } } .disabled(!canSubmit) } } private func submit() { didTrySubmit = true guard canSubmit else { return } isSaving = true // Simulando uma operação assíncrona (rede/persistência) Task { try? await Task.sleep(nanoseconds: 600_000_000) store.saveProfile(draft) isSaving = false } }}Mesmo com o botão desabilitado, o guard garante que você não envia dados inválidos caso o estado mude rapidamente (por exemplo, por alguma atualização assíncrona).
Gerenciando foco com FocusState e navegando entre inputs
Em formulários, o foco melhora muito a experiência: o usuário avança entre campos pelo teclado, você pode validar ao sair do campo, e pode abrir o teclado já no primeiro input. No SwiftUI, isso é feito com @FocusState e um enum para identificar cada campo.
1) Defina os campos focáveis
extension ProfileEditView { enum FocusField: Hashable { case fullName case email case bio } @FocusState private var focus: FocusField?}2) Aplique foco e configure submitLabel
extension ProfileEditView { @ViewBuilder var dataSectionWithFocus: some View { Section("Dados") { TextField("Nome completo", text: $draft.fullName) .focused($focus, equals: .fullName) .submitLabel(.next) .onSubmit { focus = .email } if didTrySubmit { FieldErrorText(error: nameError) } TextField("E-mail", text: $draft.email) .focused($focus, equals: .email) .keyboardType(.emailAddress) .textInputAutocapitalization(.never) .autocorrectionDisabled() .submitLabel(.next) .onSubmit { focus = .bio } if didTrySubmit { FieldErrorText(error: emailError) } } }}3) Campo multilinha e finalização
extension ProfileEditView { @ViewBuilder var bioSection: some View { Section("Sobre você", footer: Text("Opcional. Máximo de 140 caracteres.")) { TextField("Bio", text: $draft.bio, axis: .vertical) .focused($focus, equals: .bio) .lineLimit(3...6) .submitLabel(.done) .onChange(of: draft.bio) { _, newValue in if newValue.count > 140 { draft.bio = String(newValue.prefix(140)) } } .onSubmit { focus = nil } } }}O limite de caracteres é uma forma de “limite numérico” aplicado a texto. Ele previne estados inválidos e reduz frustração (o usuário não descobre no final que passou do limite).
Barra de ferramentas do teclado: botões de ação e usabilidade
Além do botão “Next/Done” do teclado, você pode adicionar uma toolbar acima do teclado com ações úteis: avançar/voltar entre campos e um botão de concluir. Isso é especialmente útil quando há campos que não disparam onSubmit como esperado (ou quando você quer consistência).
extension ProfileEditView { var keyboardToolbar: some View { ToolbarItemGroup(placement: .keyboard) { Button("Anterior") { focusPrevious() } .disabled(!canFocusPrevious) Button("Próximo") { focusNext() } .disabled(!canFocusNext) Spacer() Button("Concluir") { focus = nil } } } var canFocusPrevious: Bool { focus == .email || focus == .bio } var canFocusNext: Bool { focus == .fullName || focus == .email } func focusPrevious() { switch focus { case .email: focus = .fullName case .bio: focus = .email default: break } } func focusNext() { switch focus { case .fullName: focus = .email case .email: focus = .bio default: break } }}Para ativar essa toolbar, inclua no body:
var body: some View { Form { dataSectionWithFocus preferencesSection bioSection actionsSection } .toolbar { keyboardToolbar }Preferências e controles: Toggle e Picker dentro do Form
Formulários raramente são só texto. Toggle e Picker ficam muito naturais dentro de Form, e ajudam a reduzir erros (em vez de digitação livre).
extension ProfileEditView { @ViewBuilder var preferencesSection: some View { Section("Preferências") { Toggle("Receber newsletter", isOn: $draft.newsletter) } }}Montando a tela completa (passo a passo)
Passo 1: Crie a tela e injete o store
Em algum ponto do app, você disponibiliza o AppStore no environment e abre a tela de edição passando o perfil atual.
struct RootView: View { @State private var store = AppStore() var body: some View { NavigationStack { ProfileEditView(profile: store.profile) .navigationTitle("Editar perfil") } .environment(store) }Passo 2: Use seções e campos com foco
No ProfileEditView, componha o Form com as seções já definidas (dataSectionWithFocus, preferencesSection, bioSection, actionsSection).
Passo 3: Ative validação e mensagens no momento certo
Comece com didTrySubmit = false. Ao tocar em Salvar, marque didTrySubmit = true e mostre os erros. Se quiser refinar, você pode também validar ao perder foco (por exemplo, mostrar erro do e-mail quando o usuário sai do campo).
.onChange(of: focus) { _, newFocus in // Exemplo: ao sair do e-mail (newFocus != .email), você poderia marcar tentativa. if newFocus != .email { // didTrySubmit = true // opcional, dependendo da UX desejada }}Passo 4: Previna envio e dê feedback
Use canSubmit para desabilitar o botão e ProgressView para indicar salvamento. Isso evita toques repetidos e deixa claro que o app está trabalhando.
Padrões úteis para formulários melhores
1) Normalização antes de salvar
Antes de persistir, normalize entradas: remover espaços extras, padronizar e-mail em minúsculas, etc.
func normalized(_ profile: UserProfile) -> UserProfile { var p = profile p.fullName = p.fullName.trimmingCharacters(in: .whitespacesAndNewlines) p.email = p.email.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() p.bio = p.bio.trimmingCharacters(in: .whitespacesAndNewlines) return p}2) Direcionar foco para o primeiro erro
Quando o usuário tenta salvar e há erros, mover o foco para o primeiro campo inválido acelera a correção.
func focusFirstError() { if nameError != nil { focus = .fullName; return } if emailError != nil { focus = .email; return } // idade é Stepper, não precisa de foco de teclado if draft.bio.count > 140 { focus = .bio; return }}Chame isso dentro de submit() quando canSubmit for falso.
3) Rodapés informativos e consistentes
Use footer para regras (“Idade mínima 13”) e deixe mensagens de erro apenas para quando a regra for violada. Isso reduz a sensação de “formulário reclamando” o tempo todo.
| Elemento | Quando usar | Exemplo |
|---|---|---|
| Footer da Section | Orientação preventiva | “Use um e-mail válido para recuperar sua conta.” |
| Texto de erro | Regra violada | “Digite um e-mail válido.” |
| Botão desabilitado | Prevenir envio inválido | Salvar desabilitado até corrigir |