Skip to content

SwiftUI Lists — Data-Driven Scrollable Lists with ForEach and Sections

DodaTech Updated 2026-06-28 8 min read

In this tutorial, you will learn about SwiftUI Lists. We cover key concepts, practical examples, and best practices to help you master this topic.

SwiftUI List provides a declarative way to display scrollable rows of data with built-in support for sections, swipe actions, pull-to-refresh, search, and dynamic updates through ForEach and Identifiable data.

What You'll Learn

  • List basics with ForEach and Identifiable data
  • Sections and section headers/footers
  • Swipe actions (leading and trailing)
  • Pull-to-refresh with .refreshable
  • Search with .searchable
  • Dynamic list editing (insert, delete, reorder)
  • Performance considerations

Why It Matters

The List view is the most common UI pattern in iOS apps. SwiftUI's List simplifies table view development dramatically — no more data source methods, dequeuing cells, or manual cell registration. Combined with ForEach, it automatically animates insertions, deletions, and reordering.

Real-World Use

A messaging app uses a List with sections for each day, swipe-to-delete on individual messages, swipe-to-pin on the left, pull-to-refresh to load older messages, and a search bar to filter conversations. All of this is implemented declaratively in SwiftUI without a single UITableViewDataSource method.

Learning Path

flowchart LR
  A[SwiftUI Navigation
Lesson 27] --> B[SwiftUI Lists
You are here] B --> C[SwiftUI Animations
Lesson 29] B --> D[SwiftUI Networking
Lesson 30] style B fill:#f90,color:#fff

Basic List with ForEach

List displays rows of data. ForEach iterates over a collection to create dynamic rows.

import SwiftUI

struct Message: Identifiable {
    let id = UUID()
    let sender: String
    let text: String
    let timestamp: Date
    let isRead: Bool
}

struct BasicListView: View {
    let messages = [
        Message(sender: "Alice", text: "Hey, are you free for lunch?", timestamp: Date(), isRead: true),
        Message(sender: "Bob", text: "Meeting at 3pm", timestamp: Date().addingTimeInterval(-3600), isRead: false),
        Message(sender: "Charlie", text: "Check out this link!", timestamp: Date().addingTimeInterval(-7200), isRead: true),
        Message(sender: "Diana", text: "Happy Birthday!", timestamp: Date().addingTimeInterval(-86400), isRead: false)
    ]

    var body: some View {
        NavigationStack {
            List(messages) { message in
                HStack(spacing: 12) {
                    Circle()
                        .fill(message.isRead ? Color.gray : Color.blue)
                        .frame(width: 40, height: 40)
                        .overlay {
                            Text(message.sender.prefix(1))
                                .foregroundColor(.white)
                                .fontWeight(.bold)
                        }

                    VStack(alignment: .leading, spacing: 4) {
                        HStack {
                            Text(message.sender)
                                .fontWeight(message.isRead ? .regular : .bold)
                            Spacer()
                            Text(message.timestamp.formatted(date: .omitted, time: .shortened))
                                .font(.caption)
                                .foregroundColor(.secondary)
                        }

                        Text(message.text)
                            .font(.subheadline)
                            .foregroundColor(.secondary)
                            .lineLimit(1)
                    }
                }
                .padding(.vertical, 4)
            }
            .navigationTitle("Messages")
        }
    }
}

The List takes an array of Identifiable elements directly. Each element's id property lets SwiftUI track changes efficiently.

Sections

Use Section to group rows with headers and footers.

import SwiftUI

struct Task: Identifiable {
    let id = UUID()
    let title: String
    let priority: Priority
    let dueDate: Date

    enum Priority: String, CaseIterable {
        case high = "High"
        case medium = "Medium"
        case low = "Low"

        var color: Color {
            switch self {
            case .high: return .red
            case .medium: return .orange
            case .low: return .green
            }
        }
    }
}

struct SectionedListView: View {
    let tasks = [
        Task(title: "Submit report", priority: .high, dueDate: Date()),
        Task(title: "Buy groceries", priority: .medium, dueDate: Date().addingTimeInterval(86400)),
        Task(title: "Plan vacation", priority: .low, dueDate: Date().addingTimeInterval(604800)),
        Task(title: "Fix login bug", priority: .high, dueDate: Date()),
        Task(title: "Update docs", priority: .medium, dueDate: Date().addingTimeInterval(172800))
    ]

    var groupedTasks: [Task.Priority: [Task]] {
        Dictionary(grouping: tasks, by: { $0.priority })
    }

    var body: some View {
        NavigationStack {
            List {
                ForEach(Task.Priority.allCases, id: \.self) { priority in
                    Section {
                        ForEach(groupedTasks[priority] ?? []) { task in
                            HStack {
                                Circle()
                                    .fill(priority.color)
                                    .frame(width: 8, height: 8)
                                Text(task.title)
                                Spacer()
                                Text(task.dueDate.formatted(date: .abbreviated, time: .omitted))
                                    .font(.caption)
                                    .foregroundColor(.secondary)
                            }
                        }
                    } header: {
                        HStack {
                            Text(priority.rawValue)
                            Spacer()
                            Text("\(groupedTasks[priority]?.count ?? 0)")
                                .foregroundColor(.secondary)
                                .font(.caption)
                        }
                    } footer: {
                        Text("\(priority.rawValue) priority tasks")
                            .font(.caption)
                            .foregroundColor(.secondary)
                    }
                }
            }
            .navigationTitle("Tasks")
        }
    }
}

Sections create visual groupings with optional headers and footers. The groupedTasks dictionary groups the tasks by priority.

Swipe Actions

Add swipe actions to rows using .swipeActions.

import SwiftUI

struct SwipeActionsListView: View {
    @State private var items = ["Buy milk", "Walk dog", "Read book", "Call mom", "Write code"]
    @State private var completedItems: [String] = []

    var body: some View {
        NavigationStack {
            List {
                Section("To Do (\(items.count))") {
                    ForEach(items, id: \.self) { item in
                        Text(item)
                            .swipeActions(edge: .trailing, allowsFullSwipe: false) {
                                Button(role: .destructive) {
                                    items.removeAll { $0 == item }
                                } label: {
                                    Label("Delete", systemImage: "trash")
                                }

                                Button {
                                    items.removeAll { $0 == item }
                                    completedItems.append(item)
                                } label: {
                                    Label("Complete", systemImage: "checkmark.circle")
                                }
                                .tint(.green)
                            }
                            .swipeActions(edge: .leading, allowsFullSwipe: true) {
                                Button {
                                    print("Pinned: \(item)")
                                } label: {
                                    Label("Pin", systemImage: "pin")
                                }
                                .tint(.orange)
                            }
                    }
                }

                if !completedItems.isEmpty {
                    Section("Completed (\(completedItems.count))") {
                        ForEach(completedItems, id: \.self) { item in
                            Text(item)
                                .strikethrough()
                                .foregroundColor(.secondary)
                        }
                    }
                }
            }
            .navigationTitle("Swipe Actions")
        }
    }
}

Leading and trailing swipe edges support different actions. allowsFullSwipe enables full-swipe gesture for the first action.

Pull-to-Refresh

Add .refreshable to enable pull-to-refresh.

import SwiftUI

struct PullToRefreshView: View {
    @State private var items = ["Item 1", "Item 2", "Item 3"]
    @State private var isRefreshing = false

    var body: some View {
        NavigationStack {
            List(items, id: \.self) { item in
                Text(item)
            }
            .navigationTitle("Pull to Refresh")
            .refreshable {
                await refreshData()
            }
        }
    }

    func refreshData() async {
        print("Refreshing data...")
        try? await Task.sleep(nanoseconds: 2_000_000_000)

        let newItem = "Item \(Int.random(in: 100...999))"
        items.insert(newItem, at: 0)
        print("Added \(newItem)")
    }
}

The refreshable modifier accepts an async function. The refresh indicator automatically appears and disappears.

Searchable List

Add a search bar with .searchable.

import SwiftUI

struct SearchableListView: View {
    let allCountries = ["United States", "Canada", "Mexico", "Brazil", "United Kingdom",
                         "France", "Germany", "Italy", "Spain", "Japan", "China", "India",
                         "Australia", "New Zealand", "South Africa"]

    @State private var searchText = ""
    @State private var isSearching = false

    var filteredCountries: [String] {
        if searchText.isEmpty {
            return allCountries
        }
        return allCountries.filter { $0.localizedCaseInsensitiveContains(searchText) }
    }

    var body: some View {
        NavigationStack {
            List {
                ForEach(filteredCountries, id: \.self) { country in
                    HStack {
                        Text(country)
                        Spacer()
                        Image(systemName: "globe")
                            .foregroundColor(.blue)
                    }
                }
            }
            .navigationTitle("Countries")
            .searchable(
                text: $searchText,
                placement: .navigationBarDrawer,
                prompt: "Search countries..."
            )
            .onSubmit(of: .search) {
                print("Search submitted: \(searchText)")
            }
        }
    }
}

.searchable automatically shows and hides the search field. The list filters reactively as the search text changes.

Dynamic List Editing

Support insert, delete, and reorder operations.

import SwiftUI

struct EditableListView: View {
    @State private var items = ["First", "Second", "Third", "Fourth", "Fifth"]
    @State private var editMode: EditMode = .inactive

    var body: some View {
        NavigationStack {
            List {
                ForEach(items, id: \.self) { item in
                    Text(item)
                }
                .onDelete { indexSet in
                    items.remove(atOffsets: indexSet)
                }
                .onMove { source, destination in
                    items.move(fromOffsets: source, toOffset: destination)
                }
            }
            .navigationTitle("Edit List")
            .toolbar {
                ToolbarItem(placement: .navigationBarLeading) {
                    EditButton()
                }
                ToolbarItem(placement: .navigationBarTrailing) {
                    Button {
                        items.append("Item \(items.count + 1)")
                    } label: {
                        Label("Add", systemImage: "plus")
                    }
                }
            }
            .environment(\.editMode, $editMode)
        }
    }
}

The EditButton toggles edit mode. In edit mode, red delete circles appear and reorder handles become visible.

Complex List with Mixed Content

Lists can contain mixed static and dynamic content.

import SwiftUI

struct MixedContentListView: View {
    @State private var items = ["Apple", "Banana", "Cherry"]

    var body: some View {
        NavigationStack {
            List {
                // Static header section
                Section {
                    HStack {
                        Image(systemName: "info.circle.fill")
                            .foregroundColor(.blue)
                        Text("You have \(items.count) items")
                    }
                }

                // Dynamic items
                Section("Fruits") {
                    ForEach(items, id: \.self) { item in
                        Text(item)
                    }
                    .onDelete(perform: deleteItems)
                }

                // Static footer with button
                Section {
                    Button(action: addItem) {
                        Label("Add Fruit", systemImage: "plus.circle.fill")
                            .foregroundColor(.green)
                    }
                }

                // Another static section
                Section("About") {
                    Text("This list demonstrates mixed content in SwiftUI List.")
                        .foregroundColor(.secondary)
                }
            }
            .navigationTitle("Mixed List")
        }
    }

    func deleteItems(at offsets: IndexSet) {
        items.remove(atOffsets: offsets)
    }

    func addItem() {
        items.append("Item \(items.count + 1)")
    }
}

Common Mistakes

  1. Not using Identifiable: List and ForEach require each element to be uniquely identifiable. Either conform to Identifiable or use the id: \.self parameter.

  2. Modifying state during view update: Do not modify @State arrays inside the body or within ForEach closures. Use .onDelete and .onMove modifiers.

  3. Forgetting to use ForEach inside List for mixed content: When combining static and dynamic rows, wrap dynamic rows in ForEach inside the List.

  4. Using List for simple static content: For a few static rows, use VStack with ScrollView instead of List to avoid unnecessary overhead.

  5. Not specifying edit mode properly: The .environment(\.editMode, $editMode) binding is needed for programmatic edit mode control.

Practice Questions

  1. What is the Identifiable protocol and why is it important for lists?
  2. How do you add swipe-to-delete functionality in SwiftUI?
  3. What is the difference between .refreshable and pull-to-refresh in UIKit?
  4. How do you create sectioned lists with headers and footers?
  5. Challenge: Build a music playlist app with List containing sections for "Recently Played", "Favorites", and "All Songs". Support swipe to remove from favorites, drag to reorder the playlist, and pull-to-refresh to simulate loading new songs.

Mini Project

Create a TaskManagerList with:

  • Sections for "Today", "Tomorrow", "This Week", "Later"
  • Each task has title, due date, and priority (color indicator)
  • Swipe actions: Complete (leading, green), Delete (trailing, red), Snooze (trailing, orange)
  • Pull-to-refresh that adds a simulated new task
  • Search that filters tasks across all sections
  • Edit mode with delete and reorder
  • A button to add new tasks with an alert dialog

FAQ

What is the difference between List and VStack in ScrollView?

List provides built-in lazy loading, swipe actions, edit mode, and section support. VStack renders all views immediately but is better for simple, small collections.

How do I customize List row appearance?

Use .listRowBackground, .listRowInsets, .listRowSeparator, and .listRowSeparatorTint modifiers on the row content.

Does List support lazy loading?

Yes. List uses LazyVStack internally and only creates views for visible rows, making it efficient for large datasets.

How do I handle selection in List?

Add a @State var selection: Set and use List(data, selection: $selection) for multiple selection, or a single optional var for single selection.

Can I use List with SectionIndexTitles?

Not directly. For index titles, use the .headerProminence modifier and consider a custom alternative like a letter-jump sidebar.

What's Next

Add motion and visual flair with SwiftUI Animations, or integrate networking in the SwiftUI Networking lesson.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro