EduFlow

Section 3 · Building Screens with SwiftUI

Lists, Navigation and Forms

19 min3 min readFill in the blanks

Lesson at a glance

Show expenses in a List, push a detail screen with NavigationStack and value-based links, and collect new expenses in a validated Form presented as a sheet.

  1. 1A list of identifiable rows
  2. 2Navigation with values
  3. 3A form in a sheet

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

Pocket Budget has a store, a row view and state that drives the screen. Now it needs to become an app people can use: a list of this month’s expenses, a tap that opens the details, and a plus button that opens a form. Those three patterns (list, push navigation and modal form) make up the skeleton of most iPhone apps you’ve used.

A list of identifiable rows

ExpensesScreen.swift
import SwiftUI

struct ExpensesScreen: View {
    let store: BudgetStore
    @State private var isAdding = false

    var body: some View {
        NavigationStack {
            List {
                ForEach(store.expenses) { expense in
                    NavigationLink(value: expense) {
                        ExpenseRow(expense: expense)
                    }
                }
                .onDelete { offsets in
                    store.delete(at: offsets)
                }
            }
            .overlay {
                if store.expenses.isEmpty {
                    ContentUnavailableView("No expenses yet", systemImage: "tray",
                                           description: Text("Tap + to add your first one."))
                }
            }
            .navigationTitle("Expenses")
            .navigationDestination(for: Expense.self) { expense in
                ExpenseDetail(expense: expense)
            }
            .toolbar {
                Button("Add expense", systemImage: "plus") {
                    isAdding = true
                }
            }
            .sheet(isPresented: $isAdding) {
                AddExpenseView(store: store)
            }
        }
    }
}

That’s the whole main screen; take it one piece at a time. ForEach(store.expenses) produces a row per expense. It needs a stable identity for each element so that, when an expense is inserted or deleted, SwiftUI animates the right row instead of redrawing everything, and that identity is the id that Identifiable requires. .onDelete adds swipe-to-delete and hands you the positions to remove; the store’s delete(at:) is one line, expenses.remove(atOffsets: offsets). That method is defined in SwiftUI, so BudgetStore.swift needs import SwiftUI alongside Observation.

ContentUnavailableView is the system’s standard empty state. An empty list with no explanation looks broken; a short message and a hint at the next action looks finished.

Navigation with values

NavigationStack manages a stack of screens: the list at the bottom, details pushed on top, and the system back button and swipe gesture for free.

Navigation is split into two halves. NavigationLink(value: expense) says what was tapped. .navigationDestination(for: Expense.self) says which screen shows an Expense. The value must be Hashable, which Expense already is. Keeping the two halves apart means the destination is declared once, however many places link to an expense, and it makes programmatic navigation possible:

Swift
@State private var path: [Expense] = []

// In body:
NavigationStack(path: $path) {
    // …the same List and modifiers as above
}

// Anywhere in this view, for example after saving:
path.append(newExpense)    // pushes ExpenseDetail
path.removeAll()           // pops back to the list

The path is plain state, so navigation becomes data you can inspect, test and change from code. That’s how you’d open a specific expense from a notification, or jump back to the root after a multi-step flow. For screens with mixed destination types, NavigationPath plays the same role with type-erased values.

Inside a NavigationStack, the Expenses list pushes the Expense detail screen when a row is tapped, and back pops it. Separately, the plus button presents the Add Expense form as a sheet, which is dismissed by Save or Cancel. NavigationStack Expenses List Detail pushed tap back Add Expense sheet + button
Push navigation and a modal sheet

The detail screen can be a Form used for display, with LabeledContent rows that format values for you:

Swift
struct ExpenseDetail: View {
    let expense: Expense

    var body: some View {
        Form {
            LabeledContent("Amount", value: expense.amount, format: .currency(code: "USD"))
            LabeledContent("Category", value: expense.category.title)
            LabeledContent("Date", value: expense.date, format: .dateTime.day().month().year())
        }
        .navigationTitle(expense.title)
    }
}

A form in a sheet

Pushing is for drilling into content. A sheet is for a self-contained task the user finishes or abandons, like adding an expense. .sheet(isPresented: $isAdding) shows the form whenever isAdding becomes true. Here’s the form:

AddExpenseView.swift
struct AddExpenseView: View {
    let store: BudgetStore
    @Environment(\.dismiss) private var dismiss

    @State private var title = ""
    @State private var amount: Decimal?
    @State private var category: Category = .food
    @State private var date = Date.now

    private var canSave: Bool {
        !title.trimmingCharacters(in: .whitespaces).isEmpty && (amount ?? 0) > 0
    }

    var body: some View {
        NavigationStack {
            Form {
                Section("Details") {
                    TextField("Title", text: $title)
                    TextField("Amount", value: $amount, format: .currency(code: "USD"))
                        .keyboardType(.decimalPad)
                }
                Section {
                    Picker("Category", selection: $category) {
                        ForEach(Category.allCases, id: \.self) { category in
                            Label(category.title, systemImage: category.symbolName)
                                .tag(category)
                        }
                    }
                    DatePicker("Date", selection: $date, in: ...Date.now, displayedComponents: .date)
                }
            }
            .navigationTitle("New Expense")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Cancel") { dismiss() }
                }
                ToolbarItem(placement: .confirmationAction) {
                    Button("Save") {
                        guard let amount else { return }
                        store.add(Expense(title: title, amount: amount, category: category, date: date))
                        dismiss()
                    }
                    .disabled(!canSave)
                }
            }
        }
    }
}

The sheet wraps its form in its own NavigationStack. A sheet is a separate presentation that doesn’t inherit the list’s stack, and without one there’s no bar to hold the title or the Cancel and Save buttons. Placing them with .cancellationAction and .confirmationAction puts them where iOS users expect.

Each field binds to its own @State, so the form is a draft: nothing touches the store until Save. Cancel throws the draft away. That’s value semantics paying off again.

The amount field is the important one. TextField(value:format:) parses what the user types using their locale, so “12,50” in Germany becomes 12.5, and amount only ever holds a successfully parsed number, or nil while there isn’t one. This replaces the lenient Decimal(string:) parsing from the optionals lesson. guard let amount in the Save action unwraps it, and canSave disables the button until both title and amount make sense, so invalid input can’t be saved at all. The in: ...Date.now range stops future dates. dismiss comes from the environment and closes whatever presented this view.

Pocket Budget is now a working app with a real flow from list to detail to form. It forgets everything when you quit, though, and it knows nothing beyond the phone. The final section fixes both, starting with how Swift runs work that takes time.

Key takeaways

  • List and ForEach need to tell rows apart, which is why your models conform to Identifiable.
  • NavigationLink(value:) plus .navigationDestination(for:) separates what was tapped from which screen shows it.
  • Use a sheet for a self-contained task such as adding an expense, and dismiss it with the dismiss environment value.
  • A TextField with a format: turns typing into a typed value using the user’s locale, instead of hand-parsing strings.
  • A Picker’s .tag values must have exactly the same type as its selection, or the picker silently stops working.

Further reading

Check your understanding

3 questions

Question 1 of 3

You write NavigationLink(value: expense) but tapping a row does nothing. What’s the most likely cause?

Question 2 of 3

The category picker uses selection: $category where category is a Category, and its rows are tagged with .tag(category.rawValue). What happens?

Question 3 of 3

Why bind the amount field to a Decimal? with format: .currency(code: "USD") instead of a String?

Practice

Fill in the blanks

Wire up the main screen

Complete Pocket Budget’s main screen. Tapping a row must push ExpenseDetail for that expense using value-based navigation, and the plus button must present AddExpenseView modally while isAdding is true.

NavigationStack { List(store.expenses) { expense in NavigationLink(: expense) { ExpenseRow(expense: expense) } } .navigationTitle("Expenses") .(for: Expense.self) { expense in ExpenseDetail(expense: expense) } .toolbar { Button("Add expense", systemImage: "plus") { isAdding = true } } .(isPresented: $isAdding) { AddExpenseView(store: store) } }