Formulários no SwiftUI para iOS: Form, validação e experiência do usuário

Capítulo 7

Tempo estimado de leitura: 10 minutos

+ Exercício

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.

Continue em nosso aplicativo e ...
  • Ouça o áudio com a tela desligada
  • Ganhe Certificado após a conclusão
  • + de 5000 cursos para você explorar!
ou continue lendo abaixo...
Download App

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.

ElementoQuando usarExemplo
Footer da SectionOrientação preventiva“Use um e-mail válido para recuperar sua conta.”
Texto de erroRegra violada“Digite um e-mail válido.”
Botão desabilitadoPrevenir envio inválidoSalvar desabilitado até corrigir

Agora responda o exercício sobre o conteúdo:

Em uma tela de edição de perfil com SwiftUI, qual abordagem melhora a UX ao evitar que alterações parciais afetem o estado do app antes da confirmação?

Você acertou! Parabéns, agora siga para a próxima página

Você errou! Tente novamente.

Manter um draft evita que mudanças incompletas afetem outras telas. Ao tocar em Salvar, você valida os campos e só então persiste no estado do app, garantindo consistência e melhor experiência.

Próximo capitúlo

Persistência local no iOS com SwiftUI: UserDefaults e introdução ao SwiftData/Core Data

Arrow Right Icon
Capa do Ebook gratuito iOS para Iniciantes com SwiftUI: do zero ao primeiro app na App Store
50%

iOS para Iniciantes com SwiftUI: do zero ao primeiro app na App Store

Novo curso

14 páginas

Baixe o app para ganhar Certificação grátis e ouvir os cursos em background, mesmo com a tela desligada.