SwiftUI Lists — Data-Driven Scrollable Lists with ForEach and Sections
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
Not using Identifiable: List and ForEach require each element to be uniquely identifiable. Either conform to Identifiable or use the
id: \.selfparameter.Modifying state during view update: Do not modify
@Statearrays inside the body or within ForEach closures. Use.onDeleteand.onMovemodifiers.Forgetting to use ForEach inside List for mixed content: When combining static and dynamic rows, wrap dynamic rows in ForEach inside the List.
Using List for simple static content: For a few static rows, use VStack with ScrollView instead of List to avoid unnecessary overhead.
Not specifying edit mode properly: The
.environment(\.editMode, $editMode)binding is needed for programmatic edit mode control.
Practice Questions
- What is the Identifiable protocol and why is it important for lists?
- How do you add swipe-to-delete functionality in SwiftUI?
- What is the difference between
.refreshableand pull-to-refresh in UIKit? - How do you create sectioned lists with headers and footers?
- 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'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