Forms & User Input
Sign-up screens, booking forms, settings pages and "add expense" sheets all collect input from the user. SwiftUI's Form container gives these screens the familiar iPhone settings look, and its controls (text fields, pickers, toggles, steppers, date pickers) all work through the bindings you learned in State, Binding & Observation.
In this lesson you will build an admission form, read numbers safely, validate input before allowing submission, control the keyboard with @FocusState, and show a confirmation alert.
Form controls
Each control takes a label and a binding to a state property. Inside a Form, controls are laid out as grouped rows, and Section adds headings.
import SwiftUI
enum Course: String, CaseIterable, Identifiable {
case bca = "BCA"
case bsc = "B.Sc"
case bcom = "B.Com"
var id: Self { self }
}
struct AdmissionForm: View {
@State private var name = ""
@State private var mobile = ""
@State private var birthDate = Date.now
@State private var course = Course.bca
@State private var semester = 1
@State private var needsHostel = false
var body: some View {
NavigationStack {
Form {
Section("Student") {
TextField("Full name", text: $name)
TextField("Mobile number", text: $mobile)
.keyboardType(.numberPad)
DatePicker("Date of birth", selection: $birthDate, displayedComponents: .date)
}
Section("Course") {
Picker("Course", selection: $course) {
ForEach(Course.allCases) { option in
Text(option.rawValue).tag(option)
}
}
Stepper("Semester \(semester)", value: $semester, in: 1...6)
Toggle("Needs hostel room", isOn: $needsHostel)
}
}
.navigationTitle("Admission")
}
}
}The screen has the title "Admission" and two grouped sections. "Student" has two empty text fields with grey placeholders ("Full name", "Mobile number") and a "Date of birth" row with a compact date button that opens a calendar when tapped. "Course" has a "Course" row showing "BCA" (tapping it shows a menu with BCA, B.Sc and B.Com), a "Semester 1" row with minus and plus buttons that stop at 1 and 6, and a "Needs hostel room" switch. Tapping the mobile field brings up the number keypad.
The Picker uses the enum from Enums & switch: CaseIterable provides the options, and .tag(option) tells the picker which value each row represents. The tag's type must match the type of the selection binding exactly.
Reading numbers
A TextField can edit a number directly when you give it a value binding and a format. The text the user types is converted for you, and invalid text is ignored when editing ends.
struct FeeEntry: View {
@State private var fee = 0.0
@State private var instalments = 3
var body: some View {
Form {
TextField("Fee", value: $fee, format: .currency(code: "INR"))
.keyboardType(.decimalPad)
Picker("Instalments", selection: $instalments) {
ForEach([1, 2, 3, 4, 6], id: \.self) { count in
Text("\(count)").tag(count)
}
}
Text("Each instalment: \(fee / Double(instalments), format: .currency(code: "INR"))")
}
}
}The form shows a fee field formatted as rupees, an "Instalments" picker set to 3, and a line showing each instalment. After the user types 45000 and finishes editing, the field shows the fee in the device's currency style, for example "₹45,000.00" on a phone set to English (India), and the last line shows ₹15,000.00 in the same style. Text can format a value directly with \(value, format: ...), which respects the user's region settings.
Validating input
Keep validation rules in plain functions or computed properties. They are easy to read, and you can test them without any screen at all. Here is a rule set you can run in a playground:
import Foundation
func admissionProblem(name: String, mobile: String) -> String? {
if name.trimmingCharacters(in: .whitespaces).isEmpty {
return "Enter the student's name"
}
if mobile.count != 10 || !mobile.allSatisfy(\.isNumber) {
return "Mobile number must have 10 digits"
}
return nil
}
print(admissionProblem(name: " ", mobile: "9876543210") ?? "OK")
print(admissionProblem(name: "Isha", mobile: "98765") ?? "OK")
print(admissionProblem(name: "Isha", mobile: "9876543210") ?? "OK")Enter the student's name Mobile number must have 10 digits OK
In the view, use the same function to show a hint and to disable the submit button until the form is valid:
Section {
if let problem = admissionProblem(name: name, mobile: mobile) {
Text(problem)
.foregroundStyle(.red)
}
Button("Submit") { showConfirmation = true }
.disabled(admissionProblem(name: name, mobile: mobile) != nil)
}While the form is incomplete, a red hint line explains what is missing and the Submit button is greyed out. As soon as both fields are valid, the hint disappears and the button becomes tappable. Each keystroke changes the state, so the hint updates live. Still check the data again on the server or before saving: users can be creative.
The keyboard, focus and alerts
@FocusState tracks which field has the keyboard. Use it to move from one field to the next when the user taps Return, or to hide the keyboard after submitting. .onChange(of:) runs code when a value changes, and .alert shows a confirmation.
struct LoginView: View {
enum Field { case email, password }
@State private var email = ""
@State private var password = ""
@State private var showWelcome = false
@FocusState private var focused: Field?
var body: some View {
Form {
TextField("Email", text: $email)
.keyboardType(.emailAddress)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.focused($focused, equals: .email)
.submitLabel(.next)
.onSubmit { focused = .password }
SecureField("Password", text: $password)
.focused($focused, equals: .password)
.submitLabel(.go)
.onSubmit(signIn)
}
.onChange(of: email) { _, newValue in
email = newValue.lowercased()
}
.alert("Welcome back", isPresented: $showWelcome) {
Button("OK") { }
} message: {
Text("Signed in as \(email)")
}
}
func signIn() {
focused = nil
showWelcome = password.count >= 8
}
}The screen shows an Email field and a Password field whose characters appear as dots. The email keyboard has an @ key and does not capitalise or autocorrect. Capital letters typed in the email field become lowercase immediately. Pressing next on the keyboard jumps to the password field; pressing go hides the keyboard and, if the password has at least 8 characters, shows an alert titled "Welcome back" with "Signed in as ..." and an OK button. .onChange receives the old and new values; the _ ignores the old one.
Common mistakes
- Mismatched picker tags. If
selectionis aCourse, every.tagmust be aCourse, or the picker shows nothing selected. - Converting text to numbers by hand everywhere. Use
TextField(value:format:)orInt(text)with a clear fallback. - Validating only when Submit is tapped. Live hints and a disabled button are friendlier.
- Leaving the keyboard open after submitting. Set the
@FocusStatevalue tonil.
Key points
FormandSectiongive input screens the standard grouped look.- Controls such as
TextField,SecureField,Toggle,Stepper,PickerandDatePickerall take bindings. TextField(value:format:)edits numbers and currency directly;Textcan format values too.- Keep validation in plain functions, show live hints, and disable Submit until input is valid.
@FocusStatewith.focusedand.onSubmitcontrols the keyboard;.onChangereacts to edits..alert(_:isPresented:)confirms actions.
Exercise
Write a validation function feedbackProblem(rating: Int, comment: String) -> String? for a canteen feedback form. The rating must be 1 to 5, and a rating of 1 or 2 requires a comment of at least 10 characters. Print the result (or "OK") for (4, ""), (2, "Cold"), (2, "Rice was cold today") and (7, "Great"). Then describe how you would use it to disable a Send button in the form.
Show solution
func feedbackProblem(rating: Int, comment: String) -> String? {
guard (1...5).contains(rating) else {
return "Rating must be between 1 and 5"
}
if rating <= 2 && comment.count < 10 {
return "Please tell us what went wrong"
}
return nil
}
print(feedbackProblem(rating: 4, comment: "") ?? "OK")
print(feedbackProblem(rating: 2, comment: "Cold") ?? "OK")
print(feedbackProblem(rating: 2, comment: "Rice was cold today") ?? "OK")
print(feedbackProblem(rating: 7, comment: "Great") ?? "OK")OK Please tell us what went wrong OK Rating must be between 1 and 5
In the form, keep rating and comment in @State, bind them to a Stepper and a TextField, and add .disabled(feedbackProblem(rating: rating, comment: comment) != nil) to the Send button, showing the message in red when it is not nil.
Quick Quiz
1. What must every .tag in a Picker match?
The picker compares tags with the selection, so their types must be the same.
2. How do you edit a Double directly in a TextField?
The value:format: initializer converts between text and the value.
3. What does setting a @FocusState property to nil do?
With no focused field, the keyboard is dismissed.
4. Which modifier shows a confirmation popup when a Bool becomes true?
.alert with isPresented: shows the alert while the bound Bool is true.
Learn Swift with a mentor
Free lessons get you started. Our Mobile App Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
