Как обрабатывать формы и валидацию пользовательского ввода в 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, включая производительность, память и сопровождение.