SwiftUI Forms, Pickers, Validation and Animation

iOS App Development with Swift and SwiftUI: Beginner to Job-Ready Projects · SwiftUI · 7 min read · Updated

Validate, then save

  1. The user fills in the form: title, amount, category and date.
  2. When they tap Save, small computed properties check the input.
  3. If something is wrong, a clear message appears next to that field and nothing is saved.
  4. If everything is fine, the expense is saved and the sheet closes.

The add screen from lesson 8 works, but it looks plain, and a user could save an empty title or a negative amount. In this lesson we replace it with a real form, check the input, and add a little motion so changes feel natural and not abrupt. These are the finishing touches that make an app feel made with care.

Form and Section

Form is a container that gives its content the grouped, settings-app look, and it adapts to the platform. Inside it, Section groups related rows under a header and a footer. Controls inside a form pick up the right spacing for free.

struct ExpenseFormView: View {
    @Environment(\.dismiss) private var dismiss

    @State private var title = ""
    @State private var amount = 0.0
    @State private var category: Category = .other
    @State private var date = Date()
    @State private var note = ""
    @State private var triedToSave = false

    var onSave: (Expense) -> Void

    private var titleError: String? {
        title.trimmingCharacters(in: .whitespaces).isEmpty ? "Please enter a title." : nil
    }
    private var amountError: String? {
        amount <= 0 ? "The amount must be more than zero." : nil
    }

    var body: some View {
        NavigationStack {
            Form {
                Section("Details") {
                    TextField("Title", text: $title)
                    if triedToSave, let titleError {
                        Text(titleError).font(.caption).foregroundStyle(.red)
                    }

                    TextField("Amount", value: $amount, format: .number)
                        .keyboardType(.decimalPad)
                    if triedToSave, let amountError {
                        Text(amountError).font(.caption).foregroundStyle(.red)
                    }
                }

                Section("More") {
                    Picker("Category", selection: $category) {
                        ForEach(Category.allCases, id: \.self) { c in
                            Text(c.rawValue).tag(c)
                        }
                    }
                    DatePicker("Date", selection: $date, displayedComponents: .date)
                    TextField("Note (optional)", text: $note, axis: .vertical)
                        .lineLimit(1...4)
                }
            }
            .navigationTitle("New expense")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Cancel") { dismiss() }
                }
                ToolbarItem(placement: .confirmationAction) {
                    Button("Save") { save() }
                }
            }
        }
    }

    private func save() {
        triedToSave = true
        guard titleError == nil, amountError == nil else { return }
        onSave(Expense(title: title, amount: amount, category: category))
        dismiss()
    }
}

There is a lot here, so here is what to notice.

  • `Picker` needs a binding to the selected value and a list of choices. Each choice has a tag that matches the type of the selection. With an enum that conforms to CaseIterable, ForEach(Category.allCases) builds the whole list for you.
  • `DatePicker` gives the native date control. displayedComponents: .date hides the time.
  • `TextField(value:format:)` edits a number directly, and it follows the user's region for decimal marks. It replaces the Double(text) step from lesson 7. The field shows 0 at first, and in a real app you may prefer an optional state value or a placeholder.
  • `if let titleError` is the short form of unwrapping that works in modern Swift. It shows the message only when there is one.
  • Validation lives in small computed properties, and a save() function checks them. We show errors only after the first try to save, so the form does not shout at someone who has not typed anything yet.

Good form habits

  • Say what is wrong in plain words and next to the field, not in a pop-up.
  • Do the checks again where the data is saved, not only on the screen. In lesson 14 you will test the same rules without any screen.
  • Never rely on a field's keyboard type to keep out bad input. People can paste.
  • Mark which fields are optional.

Accessibility of forms

A form is where accessibility matters most, because people must read labels and enter data. Give every field a visible label or a clear placeholder. The built-in controls already work with VoiceOver, Apple's screen reader, and with larger text sizes, so avoid replacing them with custom ones unless you need to. When you do build a custom control, add .accessibilityLabel("...") so that it is announced with a meaningful name. Test once by turning VoiceOver on in the Simulator or on a device, and listen to how your form sounds.

Animation

SwiftUI can animate almost any change in state. The simplest tool is withAnimation. Wrap the state change in it and every view that depends on that state moves smoothly to its new look. A second tool is the .animation(_:value:) modifier, which animates whenever one particular value changes.

struct ExpandableNote: View {
    @State private var expanded = false
    @State private var liked = false

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Button {
                withAnimation(.easeInOut) { expanded.toggle() }
            } label: {
                Label(expanded ? "Hide details" : "Show details",
                      systemImage: expanded ? "chevron.up" : "chevron.down")
            }

            if expanded {
                Text("Receipts help you check your spending at the end of the month.")
                    .transition(.opacity.combined(with: .move(edge: .top)))
            }

            Image(systemName: liked ? "heart.fill" : "heart")
                .font(.largeTitle)
                .foregroundStyle(liked ? .red : .gray)
                .scaleEffect(liked ? 1.2 : 1)
                .animation(.easeInOut(duration: 0.2), value: liked)
                .onTapGesture { liked.toggle() }
        }
        .padding()
    }
}
  • withAnimation { ... } animates everything that changes because of the code inside.
  • A transition says how a view appears and disappears, as in .transition(.opacity).
  • .animation(_, value:) animates only when value changes, which stops it from animating things you did not mean to.
  • Keep motion short and quiet. Very long or bouncy animations get in the way. Many people turn on *Reduce Motion* in their phone settings, and a careful app respects it. You can read it from the environment with accessibilityReduceMotion.

Your task

  1. Replace the add sheet with ExpenseFormView.
  2. Show the error messages only after the first tap on Save, and clear them when the input is fixed.
  3. Add a ExpandableNote-style details area in the detail screen that opens with an animation.
  4. Turn on Reduce Motion in the Simulator's Accessibility settings and see how your app behaves.

How to check it worked

  • An empty title or a zero amount shows an error and does not save.
  • Choosing a category and a date saves them with the expense.
  • The expandable area opens and closes smoothly.

Frequently asked questions

What is the difference between withAnimation and the animation modifier?

withAnimation wraps a state change and animates everything affected by it. The animation(_:value:) modifier animates a view only when one particular value changes.

How do I validate a form in SwiftUI?

Write the rules as small computed properties, check them in a save function, show errors next to the fields, and repeat the checks where the data is stored.

Why use CaseIterable with a Picker?

CaseIterable gives allCases, a list of every enum case, so ForEach can build all the picker choices without you typing them again.