Almost every app is a list that leads to detail screens. In this lesson the Expense Tracker gets its first real structure: a list of expenses, a detail screen for each, and a sheet to add a new one. For now the data lives in memory, so it disappears when you close the app. Lesson 12 fixes that with SwiftData.
The model
A list needs to tell its rows apart, so each row needs a stable identity. We make the model Identifiable, with a UUID as the id. We also add Hashable, which navigation needs to remember which item was picked. A struct whose properties are all Hashable gets this for free.
import SwiftUI
enum Category: String, CaseIterable {
case food = "Food", travel = "Travel", fun = "Fun", other = "Other"
}
struct Expense: Identifiable, Hashable {
let id = UUID()
var title: String
var amount: Double
var category: Category = .other
}
List and ForEach
List shows rows that scroll, with the usual iPhone look. If you give it an array of identifiable items, it makes one row per item. Use ForEach inside a List when you want extras such as swipe to delete, or when the list has sections.
struct ExpenseListView: View {
@State private var expenses: [Expense] = [
Expense(title: "Groceries", amount: 42.8, category: .food),
Expense(title: "Bus pass", amount: 25, category: .travel),
Expense(title: "Cinema", amount: 12.5, category: .fun)
]
@State private var showAdd = false
var body: some View {
NavigationStack {
List {
ForEach(expenses) { expense in
NavigationLink(value: expense) {
HStack {
Text(expense.title)
Spacer()
Text(expense.amount, format: .currency(code: "USD"))
.foregroundStyle(.secondary)
}
}
}
.onDelete { offsets in
expenses.remove(atOffsets: offsets)
}
}
.navigationTitle("Expenses")
.navigationDestination(for: Expense.self) { expense in
ExpenseDetailView(expense: expense)
}
.toolbar {
Button("Add", systemImage: "plus") { showAdd = true }
}
.sheet(isPresented: $showAdd) {
AddExpenseView { newExpense in
expenses.append(newExpense)
}
}
}
}
}
Take the pieces one at a time.
- `NavigationStack` is the container for a stack of screens. It gives you the title bar and the back button for free.
- `NavigationLink(value:)` makes a row tappable and says *which item was chosen*. The matching `navigationDestination(for:)` says *which screen to show for that kind of item*. This pairing keeps navigation tidy even in big apps.
- `.onDelete` adds swipe to delete on a
ForEach. SwiftUI gives you the positions to remove. - `.toolbar` places buttons in the navigation bar. A
Buttonwith asystemImageshows an icon and keeps its text for accessibility. - `.sheet(isPresented:)` slides a screen up from the bottom while the boolean is true. Because we pass
$showAdd, the sheet can set it back to false when it closes.
The detail screen
struct ExpenseDetailView: View {
let expense: Expense
var body: some View {
VStack(spacing: 8) {
Text(expense.amount, format: .currency(code: "USD"))
.font(.largeTitle.bold())
Text(expense.title).font(.title3)
Text(expense.category.rawValue)
.padding(.horizontal, 10).padding(.vertical, 4)
.background(.blue.opacity(0.15), in: Capsule())
Spacer()
}
.padding()
.navigationTitle(expense.title)
.navigationBarTitleDisplayMode(.inline)
}
}
The sheet that adds an expense
The sheet needs to hand the new expense back. A simple way is to pass a closure, a function the sheet calls when the user taps Save. The sheet also uses the dismiss action from the environment to close itself. @Environment reads values the system provides, such as the dismiss action, the colour scheme or the text size.
struct AddExpenseView: View {
@Environment(\.dismiss) private var dismiss
@State private var title = ""
@State private var amountText = ""
var onSave: (Expense) -> Void
var body: some View {
NavigationStack {
VStack(spacing: 12) {
TextField("Title", text: $title)
.textFieldStyle(.roundedBorder)
TextField("Amount", text: $amountText)
.textFieldStyle(.roundedBorder)
.keyboardType(.decimalPad)
Spacer()
}
.padding()
.navigationTitle("New expense")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
if let amount = Double(amountText), !title.isEmpty {
onSave(Expense(title: title, amount: amount))
dismiss()
}
}
}
}
}
}
}
How the pieces fit together
Follow one tap through the code to see why it works. The user taps a row. The NavigationLink hands the chosen Expense to the NavigationStack. The stack looks for a navigationDestination that accepts the type Expense, builds ExpenseDetailView with that value and pushes it on screen. Pressing back removes it again. Nothing in the list needs to know what the detail screen looks like, and the detail screen does not need to know where the user came from. This separation is the reason value-based navigation scales to large apps, and also the reason a deep link or a saved navigation path can open any screen later.
The same thinking applies to the sheet. The list owns the boolean showAdd and the array. The sheet owns only what the user is typing. It gives back one finished value through the closure, and the list decides what to do with it. Each view has one job and a clear way to talk to its neighbours, and that is what makes the screens easy to change one at a time.
Common mistakes
- An array of items without a stable
id, or with anidthat changes each time. Rows jump around or lose their state. - Putting a
NavigationLinkinside another navigation container by accident, which makes two title bars. - Forgetting that the data here is only in memory. Close the app and it resets. That is expected until lesson 12.
Your task
- Type the three views into your project and run the app.
- Add a category
Pickerto the sheet usingForEach(Category.allCases, id: \.self). - Show the total of all expenses at the top of the list in a
Section. - Add an empty state: when the array is empty, show a
Text("No expenses yet")instead of a blank list.
How to check it worked
- Tapping a row opens the detail screen and the back button returns.
- The sheet adds a new row and closes. Swiping a row deletes it.
- You can explain what
NavigationLink(value:)andnavigationDestination(for:)do together.