SwiftUIMiddleTechnical

Как обрабатывать формы и валидацию пользовательского ввода в SwiftUI?

Form + Section организуют поля; валидация — в @MainActor ObservableObject с @Published-ошибками. Inline Text(.caption).foregroundStyle(.red) показывает ошибку под полем. Кнопка Submit дизейблится через .disabled(!isFormValid). Всегда валидируйте повторно в submit().

Архитектура форм в SwiftUI

SwiftUI предоставляет Form — контейнер, который автоматически адаптирует стиль элементов под платформу (grouped UITableView на iOS, стандартные поля на macOS). Внутри Form используют Section для группировки, TextField, SecureField, Toggle, Picker и другие элементы ввода.

Валидацию удобно вынести в ObservableObject — это отделяет логику проверки от представления и упрощает тестирование.

Полный пример: форма регистрации с валидацией

import SwiftUI
import Combine

// MARK: - ViewModel

@MainActor
final class RegistrationViewModel: ObservableObject {
    // Поля формы
    @Published var email = ""
    @Published var password = ""
    @Published var confirmPassword = ""
    @Published var agreeToTerms = false

    // Ошибки валидации
    @Published var emailError: String?
    @Published var passwordError: String?
    @Published var confirmError: String?

    // Состояние отправки
    @Published var isSubmitting = false
    @Published var submitError: String?
    @Published var isSuccess = false

    // Форма валидна — кнопка активна
    var isFormValid: Bool {
        emailError == nil && passwordError == nil && confirmError == nil
        && !email.isEmpty && !password.isEmpty
        && agreeToTerms
    }

    func validateEmail() {
        let pattern = #"^[A-Z0-9a-z._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$"#
        if email.isEmpty {
            emailError = "Email обязателен"
        } else if email.range(of: pattern, options: .regularExpression) == nil {
            emailError = "Некорректный email"
        } else {
            emailError = nil
        }
    }

    func validatePassword() {
        if password.count < 8 {
            passwordError = "Минимум 8 символов"
        } else if password.rangeOfCharacter(from: .decimalDigits) == nil {
            passwordError = "Нужна хотя бы одна цифра"
        } else {
            passwordError = nil
        }
    }

    func validateConfirm() {
        confirmError = password != confirmPassword ? "Пароли не совпадают" : nil
    }

    func submit() async {
        // Финальная валидация перед отправкой
        validateEmail()
        validatePassword()
        validateConfirm()
        guard isFormValid else { return }

        isSubmitting = true
        defer { isSubmitting = false }

        do {
            // Имитация сетевого запроса
            try await Task.sleep(for: .seconds(1))
            isSuccess = true
        } catch {
            submitError = error.localizedDescription
        }
    }
}

// MARK: - View

struct RegistrationView: View {
    @StateObject private var vm = RegistrationViewModel()
    @FocusState private var focusedField: FormField?

    enum FormField: Hashable {
        case email, password, confirm
    }

    var body: some View {
        NavigationStack {
            Form {
                Section("Учётная запись") {
                    VStack(alignment: .leading, spacing: 4) {
                        TextField("Email", text: $vm.email)
                            .focused($focusedField, equals: .email)
                            .keyboardType(.emailAddress)
                            .textInputAutocapitalization(.never)
                            .autocorrectionDisabled()
                            .submitLabel(.next)
                            .onSubmit { focusedField = .password }
                            .onChange(of: focusedField) { _, f in
                                if f != .email { vm.validateEmail() }
                            }

                        if let err = vm.emailError {
                            Text(err)
                                .font(.caption)
                                .foregroundStyle(.red)
                        }
                    }

                    VStack(alignment: .leading, spacing: 4) {
                        SecureField("Пароль", text: $vm.password)
                            .focused($focusedField, equals: .password)
                            .submitLabel(.next)
                            .onSubmit { focusedField = .confirm }
                            .onChange(of: focusedField) { _, f in
                                if f != .password { vm.validatePassword() }
                            }

                        if let err = vm.passwordError {
                            Text(err)
                                .font(.caption)
                                .foregroundStyle(.red)
                        }
                    }

                    VStack(alignment: .leading, spacing: 4) {
                        SecureField("Повторите пароль", text: $vm.confirmPassword)
                            .focused($focusedField, equals: .confirm)
                            .submitLabel(.done)
                            .onSubmit {
                                focusedField = nil
                                vm.validateConfirm()
                            }
                            .onChange(of: vm.confirmPassword) { _, _ in
                                if !vm.confirmPassword.isEmpty {
                                    vm.validateConfirm()
                                }
                            }

                        if let err = vm.confirmError {
                            Text(err)
                                .font(.caption)
                                .foregroundStyle(.red)
                        }
                    }
                }

                Section {
                    Toggle("Согласен с условиями использования", isOn: $vm.agreeToTerms)
                }

                if let err = vm.submitError {
                    Section {
                        Text(err)
                            .foregroundStyle(.red)
                    }
                }

                Section {
                    Button {
                        Task { await vm.submit() }
                    } label: {
                        if vm.isSubmitting {
                            ProgressView()
                                .frame(maxWidth: .infinity)
                        } else {
                            Text("Зарегистрироваться")
                                .frame(maxWidth: .infinity)
                        }
                    }
                    .disabled(!vm.isFormValid || vm.isSubmitting)
                }
            }
            .navigationTitle("Регистрация")
        }
        .onAppear { focusedField = .email }
    }
}

Live-валидация vs валидация при потере фокуса

В примере выше валидация email и пароля запускается при уходе из поля (onChange of focusedField), а подтверждение пароля — в реальном времени при каждом символе. Это стандартный UX: не ругайте пользователя, пока он ещё печатает первое поле, но сразу показывайте несовпадение при вводе подтверждения.

Подводные камни

  • Кнопка Submit не должна зависеть только от isFormValid без финальной проверки. Пользователь может не трогать поле и оставить его пустым. Всегда вызывайте все validate-методы в submit() перед сетевым запросом.
  • Form на iOS — это ScrollView + List. Если добавить внешний ScrollView, появится двойная прокрутка. Не оборачивайте Form в дополнительный ScrollView.
  • disabled() не блокирует программный вызов submit. Если submit вызывается из другого места (например, по хардварной кнопке Enter), валидация должна быть на стороне ViewModel, а не только в кнопке.
  • @Published-ошибки и @MainActor. Если валидация запускается из фонового потока (например, через debounce в Combine), обновление @Published без @MainActor вызовет предупреждение или краш. Маркируйте ViewModel как @MainActor или используйте await MainActor.run { ... }.
  • Нет встроенного debounce в SwiftUI. Для live-валидации при каждом символе используйте Combine: $email.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main).sink { ... }. Без debounce регулярное выражение запускается на каждое нажатие.
  • SecureField не поддерживает textContentType автоматически на iOS 16-. Явно ставьте .textContentType(.newPassword), иначе Safari не предложит сохранить пароль.
  • Picker внутри Form требует NavigationStack. Без навигационного контейнера стиль .navigationLink (по умолчанию) не откроет список выбора.
  • Inline-ошибки ломают выравнивание Form. Если добавлять Text под полем внутри Form/Section, ячейка динамически меняет высоту — это выглядит хаотично. Рассмотрите overlay или фиксированную высоту для области ошибки.

Common mistakes

  • Сводить «обрабатывать формы и валидацию пользовательского ввода в SwiftUI» к синтаксису и не объяснять environment.
  • Игнорировать жизненный цикл, основной поток или момент освобождения ресурсов в сценарии swiftui-18.
  • Выбирать API по привычке, не проверяя состояние, ошибки, доступность и платформенные ограничения.

What the interviewer is testing

  • Формулирует точную модель для «обрабатывать формы и валидацию пользовательского ввода в SwiftUI» и подтверждает ее корректным примером.
  • Умеет связать ответ с main actor, тестированием и отладкой на устройстве.
  • Называет ограничения подхода swiftui-18, включая производительность, память и сопровождение.

Sources

Related topics