The 30-second answer
Use List(items) with Identifiable data for the simplest case. Add .refreshable for pull to refresh, .searchable for search, .swipeActions for swipe to delete, and pagination via .onAppear on the last row. For 10,000 plus rows, List wins on performance vs LazyVStack thanks to cell recycling. iOS 26 added Liquid Glass row separators and per row spacing.
Lists are the single most common UI primitive in iOS apps. This guide covers every pattern you will need in 2026: basics, sections, selection, swipe actions, pull to refresh with async/await, search with scopes, infinite scroll pagination, custom row separators, and Liquid Glass support. Each pattern has working code you can paste into a project.
List Basics
The simplest List is one line. Pass an array of Identifiable items and SwiftUI handles the rest.
struct Recipe: Identifiable {
let id = UUID()
let name: String
let prepTime: Int
}
struct ContentView: View {
let recipes = [
Recipe(name: "Avocado Toast", prepTime: 5),
Recipe(name: "Pasta Carbonara", prepTime: 20),
Recipe(name: "Pad Thai", prepTime: 30),
]
var body: some View {
List(recipes) { recipe in
VStack(alignment: .leading) {
Text(recipe.name).font(.headline)
Text("\(recipe.prepTime) minutes").font(.caption)
.foregroundStyle(.secondary)
}
}
}
}List with ForEach (when you need conditional content)
When you need sections or conditional rows, wrap with ForEach:
List {
if showFavorites {
Section("Favorites") {
ForEach(favorites) { recipe in
RecipeRow(recipe: recipe)
}
}
}
Section("All Recipes") {
ForEach(recipes) { recipe in
RecipeRow(recipe: recipe)
}
}
}List Styles in 2026
Apply .listStyle() to switch the visual treatment.
| Style | When to use | iOS support |
|---|---|---|
.plain | Flat list, no insets, edge to edge | iOS 14 plus |
.grouped | Classic Settings app style with grouped rows | iOS 14 plus |
.insetGrouped | Modern grouped style with rounded corners (default on iOS 13 plus) | iOS 14 plus |
.sidebar | Outline view for iPad and macOS sidebars | iOS 14 plus |
.carousel | Horizontal scrolling cards (iOS 26 plus) | iOS 26 plus |
List(recipes) { recipe in
RecipeRow(recipe: recipe)
}
.listStyle(.insetGrouped)
.scrollContentBackground(.hidden)
.background(Color(.systemGroupedBackground))Sections and Headers
List {
Section {
ForEach(breakfastRecipes) { RecipeRow(recipe: $0) }
} header: {
Text("Breakfast")
} footer: {
Text("\(breakfastRecipes.count) recipes available")
}
Section("Lunch") {
ForEach(lunchRecipes) { RecipeRow(recipe: $0) }
}
}The shortcut Section("Lunch") uses the string as a header. The longer form lets you customize header and footer with arbitrary views.
Selection: Single and Multi
For single selection, bind a state variable to the row identifier:
@State private var selectedRecipe: Recipe.ID?
List(recipes, selection: $selectedRecipe) { recipe in
Text(recipe.name)
}For multi selection, use a Set of identifiers and pair with EditButton:
@State private var selection = Set<Recipe.ID>()
NavigationStack {
List(recipes, selection: $selection) { recipe in
Text(recipe.name)
}
.toolbar {
EditButton()
}
.toolbar {
if !selection.isEmpty {
ToolbarItem(placement: .bottomBar) {
Button("Delete (\(selection.count))", role: .destructive) {
delete(selection)
}
}
}
}
}Swipe Actions
The swipeActions modifier attaches buttons to each row. Up to three actions per edge.
List(recipes) { recipe in
RecipeRow(recipe: recipe)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
delete(recipe)
} label: {
Label("Delete", systemImage: "trash")
}
Button {
archive(recipe)
} label: {
Label("Archive", systemImage: "archivebox")
}
.tint(.orange)
}
.swipeActions(edge: .leading) {
Button {
toggleFavorite(recipe)
} label: {
Label("Favorite", systemImage: "star")
}
.tint(.yellow)
}
}The first trailing action becomes the full swipe action by default. Set allowsFullSwipe: false to disable it. Swipe actions respect VoiceOver via the rotor automatically.
Pull to Refresh with async/await
One modifier, async closure, system handles the spinner.
@MainActor
@Observable
final class RecipeListModel {
var recipes: [Recipe] = []
func reload() async {
do {
let response = try await api.fetchRecipes()
recipes = response
} catch {
print("Failed to reload:", error)
}
}
}
struct RecipeListView: View {
@State private var model = RecipeListModel()
var body: some View {
List(model.recipes) { recipe in
RecipeRow(recipe: recipe)
}
.refreshable {
await model.reload()
}
}
}The system shows the standard pull to refresh spinner and waits for the async closure to complete. No need for completion handlers or manually showing or hiding the indicator.
Search with searchable
@State private var searchText = ""
var filtered: [Recipe] {
searchText.isEmpty ? recipes : recipes.filter {
$0.name.localizedCaseInsensitiveContains(searchText)
}
}
List(filtered) { recipe in
RecipeRow(recipe: recipe)
}
.searchable(text: $searchText, prompt: "Search recipes")Search Scopes
enum SearchScope: String, CaseIterable {
case all, breakfast, lunch, dinner
}
@State private var scope: SearchScope = .all
List(filtered) { recipe in
RecipeRow(recipe: recipe)
}
.searchable(text: $searchText)
.searchScopes($scope) {
ForEach(SearchScope.allCases, id: \.self) { scope in
Text(scope.rawValue.capitalized).tag(scope)
}
}Pagination (Infinite Scroll)
Two patterns. The classic onAppear on the last row, and the iOS 17 plus onScrollTargetVisibilityChange for cleaner detection.
@MainActor
@Observable
final class PaginatedModel {
var items: [Recipe] = []
var page = 0
var isLoading = false
var hasMore = true
func loadMore() async {
guard !isLoading, hasMore else { return }
isLoading = true
defer { isLoading = false }
do {
let next = try await api.fetchRecipes(page: page)
items.append(contentsOf: next)
page += 1
hasMore = !next.isEmpty
} catch {
print("Pagination failed:", error)
}
}
}
struct PaginatedListView: View {
@State private var model = PaginatedModel()
var body: some View {
List(model.items) { recipe in
RecipeRow(recipe: recipe)
.onAppear {
if recipe.id == model.items.last?.id {
Task { await model.loadMore() }
}
}
}
.task { await model.loadMore() }
.overlay(alignment: .bottom) {
if model.isLoading {
ProgressView().padding()
}
}
}
}Customizing Separators and Row Backgrounds
List(recipes) { recipe in
RecipeRow(recipe: recipe)
.listRowSeparator(.hidden)
.listRowSeparatorTint(.gray.opacity(0.3))
.listRowBackground(
RoundedRectangle(cornerRadius: 12)
.fill(.regularMaterial)
.padding(.vertical, 4)
)
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
}
.listStyle(.plain)
.listRowSpacing(8) // iOS 26 plusTo remove all chrome and get a fully custom appearance, set .listStyle(.plain) and apply your own row backgrounds. The scrollContentBackground(.hidden) modifier removes the system background entirely so you can layer your own.
iOS 26 Additions
Two new modifiers in iOS 26:
.listRowSpacing(8): explicit spacing between rows without resorting to padding hacks..listRowSeparatorStyle(.glass): Liquid Glass treatment for separators that adapts to the underlying content.
List vs LazyVStack: When to Pick Which
| Use case | List | LazyVStack |
|---|---|---|
| Standard iOS list UI | Win | Reimplement chrome |
| Custom row backgrounds | Possible with effort | Win |
| 10,000 plus rows | Cell recycling wins | Slower |
| Swipe actions, swipe to delete | Built in | Reimplement |
| Sticky section headers | Built in | Manual GeometryReader |
| Edge to edge full width content | Use .plain plus zero insets | Default |
| Pull to refresh | Built in | Reimplement with ScrollView refreshable |
Default to List unless you have a specific reason to need full layout control. The iOS list chrome is what users expect.
Performance: Why Your List is Slow
Five common causes of List performance issues with hundreds or thousands of rows:
- Expensive work in
body. Image decoding, date formatting, sorting, or filtering inside the row body re runs on every redraw. Compute once in the model and pass formatted strings. - Using indices instead of
Identifiable. Indices change when the array mutates, breaking diffing and forcing full redraws. Always use stable IDs. - Unstable data source. Returning a new array reference on every read defeats the diffing. Use
@Observablewith stable arrays. - Too many sticky section headers. Each sticky header costs measurable layout work. For 100 plus sections, switch to a single header at the top and rely on a plain list style.
- Complex row content. If your row contains 20 plus subviews with nested ZStacks, consider extracting to a separate view and using
EquatableViewto skip redraws when input is unchanged.
For deeper performance work, see SwiftUI Performance Optimization Tips.
Accessibility
- List rows are buttons by default for VoiceOver, with the row content read aloud.
- Add
.accessibilityLabeland.accessibilityHintfor rich rows that combine multiple text elements. - Swipe actions are exposed via the VoiceOver rotor automatically.
- Use
Dynamic Typefriendly fonts (.headline,.body) so rows reflow at large sizes. - Test with VoiceOver and Voice Control on. Never rely on color alone to convey state.
The Swift Kit Ships Production List Patterns
The Swift Kit ships pre built list patterns for the common iOS app cases: a paginated feed list with pull to refresh, a searchable settings list, a swipe to delete archive list, and a sectioned content browser. Each one uses the design system tokens (corner radius, spacing, typography) so changing one value retheme the entire app.
Frequently Asked Questions
Why does my List re render every time data changes?
Most likely your row view contains expensive work in body, or your data source returns a new array reference on every read. Move expensive work to the model, ensure rows conform to Identifiable, and consider EquatableView on rows that take rich input.
Can I have a horizontal scrolling List?
Use .listStyle(.carousel) on iOS 26 plus, or compose ScrollView(.horizontal) with LazyHStack for earlier iOS versions.
How do I show an empty state for a List?
Conditionally render either the List or an empty state view. Use ContentUnavailableView on iOS 17 plus for the system styled empty state with icon, title, and description.
Does List work inside a NavigationStack?
Yes. Wrap List in NavigationStack, use NavigationLink for row destinations, and call .navigationTitle on the List for the navigation bar title.
Where to Go Next
- SwiftUI Sheets Field Guide for presenting detail views from list rows.
- SwiftUI Charts Tutorial for adding analytics charts above your list.
- SwiftUI Performance Tips for deeper performance work.