Lists and Navigation in SwiftUI: `List`, `NavigationStack`, and Detail Views


Almost every app on your iPhone follows the same pattern: you see a scrollable list of items, tap one, and a detail screen slides in from the right. The Settings app, Mail, Notes, Music — they all work this way. Pixar’s own Monsters, Inc. has a similar structure: a floor full of doors (the list), and behind each door is a unique child’s room (the detail view).

This guide covers how to build that pattern with List, NavigationStack, and NavigationLink. You will also learn ForEach for dynamic data and programmatic navigation for when you need to push views from code. We won’t cover tab bars or toolbar customization — those are separate topics.

This post assumes you are comfortable with state management in SwiftUI.

What You’ll Learn

What Is a List?

A List is a scrollable container that displays rows of data vertically. It automatically adds the familiar iOS styling — rounded row backgrounds, separators between rows, and smooth scrolling.

Apple Docs: List — SwiftUI

Think of a List like the door vault in Monsters, Inc. — a long conveyor of doors, each one representing a unique entry. You scroll through them and pick the one you want.

Creating a Basic List

The simplest list contains static rows — content you write directly in the code:

List {
    Text("Toy Story")
    Text("Finding Nemo")
    Text("Up")
    Text("WALL-E")
    Text("Coco")
}
┌─────────────────────────┐
│ Toy Story               │
├─────────────────────────┤
│ Finding Nemo            │
├─────────────────────────┤
│ Up                      │
├─────────────────────────┤
│ WALL-E                  │
├─────────────────────────┤
│ Coco                    │
└─────────────────────────┘

Each Text view becomes a row. SwiftUI adds separators and scrolling behavior automatically. You don’t need to manage scroll positions or row recycling — List handles it all.

Dynamic Lists with ForEach

Static lists are fine for a few items, but real apps display data from arrays, databases, or network responses. ForEach generates rows dynamically from a collection.

struct PixarMovie: Identifiable {
    let id = UUID()
    let title: String
    let year: Int
}

let movies = [
    PixarMovie(title: "Toy Story", year: 1995),
    PixarMovie(title: "Finding Nemo", year: 2003),
    PixarMovie(title: "Up", year: 2009),
    PixarMovie(title: "Inside Out", year: 2015),
    PixarMovie(title: "Coco", year: 2017),
]
List {
    ForEach(movies) { movie in
        HStack {
            Text(movie.title)
            Spacer()
            Text("\(movie.year)")
                .foregroundStyle(.secondary)
        }
    }
}
┌──────────────────────────────┐
│ Toy Story              1995  │
├──────────────────────────────┤
│ Finding Nemo           2003  │
├──────────────────────────────┤
│ Up                     2009  │
├──────────────────────────────┤
│ Inside Out             2015  │
├──────────────────────────────┤
│ Coco                   2017  │
└──────────────────────────────┘

Two important details here:

  1. Identifiable protocolForEach needs a way to tell rows apart. By conforming PixarMovie to Identifiable and adding an id property, each row gets a unique identity. This lets SwiftUI animate insertions, deletions, and moves correctly.

  2. The closureForEach(movies) { movie in ... } loops through the array and creates a view for each element. The movie parameter gives you access to the current item.

Tip: You can also pass an id key path instead of conforming to Identifiable: ForEach(movies, id: \.title) { movie in ... }. This works when your data doesn’t have a dedicated id property.

Adding Navigation with NavigationStack

A list becomes truly useful when tapping a row takes you to a detail screen. NavigationStack provides the container for this push-and-pop navigation pattern.

Apple Docs: NavigationStack — SwiftUI

Wrap your List inside a NavigationStack and use NavigationLink to make rows tappable:

Apple Docs: NavigationLink — SwiftUI

NavigationStack {
    List {
        ForEach(movies) { movie in
            NavigationLink(value: movie) {
                Text(movie.title)
            }
        }
    }
    .navigationTitle("Pixar Movies")
    .navigationDestination(for: PixarMovie.self) { movie in
        MovieDetailView(movie: movie)
    }
}
┌──────────────────────────────┐
│ Pixar Movies           (nav) │
├──────────────────────────────┤
│ Toy Story                  > │
├──────────────────────────────┤
│ Finding Nemo               > │
├──────────────────────────────┤
│ Up                         > │
├──────────────────────────────┤
│ Inside Out                 > │
├──────────────────────────────┤
│ Coco                       > │
└──────────────────────────────┘

Here is what each piece does:

  • NavigationStack wraps the entire view and manages a navigation path (a stack of screens).
  • NavigationLink(value: movie) makes a row tappable and associates the movie data with the tap.
  • .navigationTitle("Pixar Movies") sets the large title at the top.
  • .navigationDestination(for: PixarMovie.self) tells SwiftUI what view to push when a PixarMovie value is tapped.

Note: For .navigationDestination to work with your custom type, the type must conform to Hashable. Add Hashable conformance to your struct:

struct PixarMovie: Identifiable, Hashable {
    let id = UUID()
    let title: String
    let year: Int
}

Building Detail Views

The detail view is the screen that appears when you tap a row. It receives the selected item and displays its full information:

struct MovieDetailView: View {
    let movie: PixarMovie

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text(movie.title)
                .font(.largeTitle)
                .bold()
            Text("Released: \(movie.year)")
                .font(.title3)
                .foregroundStyle(.secondary)
            Spacer()
        }
        .padding()
        .navigationTitle(movie.title)
        .navigationBarTitleDisplayMode(.inline)
    }
}
┌──────────────────────────────┐
│ < Back        Toy Story      │
├──────────────────────────────┤
│                              │
│ Toy Story                    │
│ Released: 1995               │
│                              │
│                              │
│                              │
└──────────────────────────────┘

SwiftUI automatically adds a Back button at the top-left showing the previous screen’s title. The .navigationBarTitleDisplayMode(.inline) keeps the title compact in the navigation bar instead of using the large title style.

Programmatic Navigation

Sometimes you need to navigate from code rather than from a user tap — for example, after saving data or completing a task. NavigationStack supports this with a path binding.

struct MovieListView: View {
    @State private var path: [PixarMovie] = []

    var body: some View {
        NavigationStack(path: $path) {
            List {
                ForEach(movies) { movie in
                    NavigationLink(value: movie) {
                        Text(movie.title)
                    }
                }
                Button("Jump to WALL-E") {
                    if let wallE = movies.first(where: {
                        $0.title == "WALL-E"
                    }) {
                        path.append(wallE)
                    }
                }
            }
            .navigationTitle("Pixar Movies")
            .navigationDestination(for: PixarMovie.self) { movie in
                MovieDetailView(movie: movie)
            }
        }
    }
}

The path array represents the navigation stack. When you append a movie to path, SwiftUI pushes the corresponding detail view. When you remove items, SwiftUI pops views. Setting path = [] returns to the root.

This is powerful for deep linking, onboarding flows, or any scenario where navigation depends on logic rather than direct user taps.

Common Mistakes

Forgetting Identifiable on Your Data Model

ForEach requires each item to be uniquely identifiable. Without Identifiable (or an explicit id parameter), your code won’t compile.

// ❌ Missing Identifiable — won't compile
struct Movie {
    let title: String
}
ForEach(movies) { movie in Text(movie.title) }
// ✅ Add Identifiable with a unique id
struct Movie: Identifiable {
    let id = UUID()
    let title: String
}
ForEach(movies) { movie in Text(movie.title) }

Putting navigationTitle Outside NavigationStack

The .navigationTitle() modifier must be applied to a view inside the NavigationStack, not on the NavigationStack itself.

// ❌ Title won't appear
NavigationStack {
    List { /* ... */ }
}
.navigationTitle("Movies")
// ✅ Title applied inside the stack
NavigationStack {
    List { /* ... */ }
        .navigationTitle("Movies")
}

Using the Old NavigationView

NavigationView was deprecated in iOS 16. Always use NavigationStack for new projects. It supports type-safe destinations, programmatic navigation, and deep linking — features that NavigationView lacks.

// ❌ Deprecated
NavigationView {
    List { /* ... */ }
}
// ✅ Modern approach
NavigationStack {
    List { /* ... */ }
}

What’s Next?

  • List creates scrollable, styled rows with minimal code.
  • ForEach generates rows dynamically from collections — data must be Identifiable.
  • NavigationStack manages a stack of screens with push-and-pop behavior.
  • NavigationLink makes rows tappable; .navigationDestination defines what gets pushed.
  • Programmatic navigation uses a path binding to control the stack from code.

You now have the fundamental building blocks of most iOS apps: views, layout, state, lists, and navigation. Ready to collect user input? Continue to Forms and User Input in SwiftUI to learn about text fields, toggles, pickers, and form validation.