NewThe Flutter Kit — Flutter boilerplate$149$69
Tutorial

SwiftUI Lists Mastery 2026: From Basics to Custom Pull to Refresh

Every SwiftUI List pattern you need in 2026. Basics, list styles, sections, selection, swipe actions, pull to refresh, search, pagination, and the iOS 26 Liquid Glass treatment, with working code for each.

Ahmed GaganAhmed Gagan
14 min read

Skip 100+ hours of setup. Get The Swift Kit $149 $99 one-time

Get it now →

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.

StyleWhen to useiOS support
.plainFlat list, no insets, edge to edgeiOS 14 plus
.groupedClassic Settings app style with grouped rowsiOS 14 plus
.insetGroupedModern grouped style with rounded corners (default on iOS 13 plus)iOS 14 plus
.sidebarOutline view for iPad and macOS sidebarsiOS 14 plus
.carouselHorizontal 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 plus

To 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 caseListLazyVStack
Standard iOS list UIWinReimplement chrome
Custom row backgroundsPossible with effortWin
10,000 plus rowsCell recycling winsSlower
Swipe actions, swipe to deleteBuilt inReimplement
Sticky section headersBuilt inManual GeometryReader
Edge to edge full width contentUse .plain plus zero insetsDefault
Pull to refreshBuilt inReimplement 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:

  1. 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.
  2. Using indices instead of Identifiable. Indices change when the array mutates, breaking diffing and forcing full redraws. Always use stable IDs.
  3. Unstable data source. Returning a new array reference on every read defeats the diffing. Use @Observable with stable arrays.
  4. 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.
  5. Complex row content. If your row contains 20 plus subviews with nested ZStacks, consider extracting to a separate view and using EquatableView to 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 .accessibilityLabel and .accessibilityHint for rich rows that combine multiple text elements.
  • Swipe actions are exposed via the VoiceOver rotor automatically.
  • Use Dynamic Type friendly 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

Share this article
Limited-time · price rises to $149 soon

Ready to ship your iOS app faster?

The Swift Kit gives you a production-ready SwiftUI codebase with onboarding, paywalls, auth, AI integrations, and a full design system. Stop rebuilding boilerplate — start building your product.

$149$99one-time · save $50
  • Full source code
  • Unlimited projects
  • Lifetime updates
  • 50+ makers shipping