SwiftUI Lists, NavigationStack and Sheets

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

List to detail

  1. A List shows one row for each item in your array.
  2. Tapping a row sends the chosen item through its NavigationLink.
  3. navigationDestination decides which screen belongs to that type of item.
  4. The detail screen opens with the item, and the back button comes for free.

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 Button with a systemImage shows 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 an id that changes each time. Rows jump around or lose their state.
  • Putting a NavigationLink inside 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

  1. Type the three views into your project and run the app.
  2. Add a category Picker to the sheet using ForEach(Category.allCases, id: \.self).
  3. Show the total of all expenses at the top of the list in a Section.
  4. 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:) and navigationDestination(for:) do together.

Frequently asked questions

What is the difference between List and ForEach?

List is the scrolling container with the standard look. ForEach makes views from a collection and works inside List, which lets you add swipe to delete or sections.

Should I use NavigationView or NavigationStack?

NavigationStack. NavigationView is deprecated for new code. NavigationStack supports value-based links and programmatic navigation.

Why must list items be Identifiable?

SwiftUI needs a stable id for each row to know which row is which when data changes. Identifiable types provide that id, and a UUID is a simple way to make one.