```html SwiftUI: How to Multi-Select in Lists (iOS 17+, 2026)

How to implement multi-select in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: List, EditMode Updated: May 11, 2026
TL;DR

Pass a Binding<Set<Item.ID>> to List and toggle EditMode to enable SwiftUI's built-in multi-select checkboxes — no custom row tap logic required.

struct Item: Identifiable { let id = UUID(); var title: String }

struct MultiSelectView: View {
    @State private var items = [Item(title: "Alpha"), Item(title: "Beta")]
    @State private var selection = Set<UUID>()
    @Environment(\.editMode) private var editMode

    var body: some View {
        List(items, selection: $selection) { item in
            Text(item.title)
        }
        .toolbar {
            EditButton()
        }
    }
}

Full implementation

The complete example adds a toolbar with an EditButton (which manages EditMode automatically), a live selection count badge, and a "Delete selected" button that only becomes active when rows are chosen. The selection set is typed to the model's ID — a UUID here — so SwiftUI can match rows without you writing any tap handlers. Accessibility labels on each row ensure VoiceOver announces checked/unchecked state correctly.

import SwiftUI

struct Fruit: Identifiable {
    let id = UUID()
    var name: String
    var emoji: String
}

struct MultiSelectFruitView: View {
    @State private var fruits: [Fruit] = [
        Fruit(name: "Apple",      emoji: "🍎"),
        Fruit(name: "Banana",     emoji: "🍌"),
        Fruit(name: "Cherry",     emoji: "🍒"),
        Fruit(name: "Date",       emoji: "🌴"),
        Fruit(name: "Elderberry", emoji: "🫐"),
        Fruit(name: "Fig",        emoji: "🍇"),
    ]

    // Selection is a Set of the model's ID type (UUID)
    @State private var selection = Set<UUID>()
    @State private var showDeleteConfirm = false

    var body: some View {
        NavigationStack {
            List(fruits, selection: $selection) { fruit in
                Label {
                    Text(fruit.name)
                        .font(.body)
                } icon: {
                    Text(fruit.emoji)
                        .font(.title3)
                }
                .accessibilityLabel("\(fruit.name), \(selection.contains(fruit.id) ? "selected" : "not selected")")
            }
            .navigationTitle("Fruits")
            .toolbar {
                ToolbarItem(placement: .topBarLeading) {
                    EditButton()
                }
                ToolbarItem(placement: .topBarTrailing) {
                    Button(role: .destructive) {
                        showDeleteConfirm = true
                    } label: {
                        Label("Delete", systemImage: "trash")
                    }
                    .disabled(selection.isEmpty)
                    .confirmationDialog(
                        "Delete \(selection.count) item\(selection.count == 1 ? "" : "s")?",
                        isPresented: $showDeleteConfirm,
                        titleVisibility: .visible
                    ) {
                        Button("Delete", role: .destructive) {
                            deleteSelected()
                        }
                    }
                }
            }
            .safeAreaInset(edge: .bottom) {
                if !selection.isEmpty {
                    selectionBanner
                }
            }
        }
    }

    // MARK: - Selection banner

    private var selectionBanner: some View {
        Text("\(selection.count) selected")
            .font(.footnote.weight(.semibold))
            .padding(.horizontal, 16)
            .padding(.vertical, 10)
            .background(.thinMaterial, in: Capsule())
            .padding(.bottom, 12)
            .transition(.move(edge: .bottom).combined(with: .opacity))
            .animation(.spring(response: 0.3), value: selection.count)
    }

    // MARK: - Actions

    private func deleteSelected() {
        withAnimation {
            fruits.removeAll { selection.contains($0.id) }
            selection.removeAll()
        }
    }
}

#Preview {
    MultiSelectFruitView()
}

How it works

  1. Selection binding on ListList(fruits, selection: $selection) tells SwiftUI to track which rows are checked. SwiftUI compares each row's id against the Set; rows whose ID is present render with a filled checkmark. No custom onTapGesture needed.
  2. EditButton manages EditMode automaticallyEditButton() in the toolbar toggles the environment's \.editMode value between .active and .inactive. Checkboxes are only visible and tappable while editMode == .active.
  3. Disabling the Delete button when nothing is selected.disabled(selection.isEmpty) ensures the destructive action is unreachable until at least one row is checked, preventing accidental empty-delete calls.
  4. Animated selection banner — The safeAreaInset(edge: .bottom) banner uses .animation(.spring, value: selection.count) so it smoothly slides in and out without affecting the list's own scroll area.
  5. Accessibility label on rows — The custom .accessibilityLabel appends "selected" or "not selected" so VoiceOver users get the same feedback as sighted users seeing the checkmark.

Variants

Programmatic EditMode (no EditButton)

When you need to enter multi-select mode in response to a long-press or custom gesture rather than the system toolbar button, inject editMode manually via the environment.

struct ProgrammaticMultiSelect: View {
    @State private var items = ["Inbox", "Drafts", "Sent", "Trash"]
    @State private var selection = Set<String>()
    @State private var editMode: EditMode = .inactive

    var body: some View {
        List(items, id: \.self, selection: $selection) { item in
            Text(item)
        }
        // Inject editMode into the environment manually
        .environment(\.editMode, $editMode)
        .onLongPressGesture {
            withAnimation { editMode = .active }
        }
        .toolbar {
            if editMode == .active {
                ToolbarItem(placement: .topBarTrailing) {
                    Button("Done") {
                        withAnimation { editMode = .inactive }
                    }
                }
            }
        }
    }
}

Select All / Deselect All

Add a "Select All" button that populates the selection set in one tap:

selection = Set(fruits.map(\.id))

And "Deselect All" with:

selection.removeAll()

Toggle between them in a single toolbar button by checking whether selection.count == fruits.count.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement multi-select in SwiftUI for iOS 17+.
Use List with a Set<ID> selection binding and EditMode.
Include a toolbar EditButton, a "Delete selected" button
that is disabled when nothing is selected, and an animated
selection count banner using safeAreaInset.
Make it accessible (VoiceOver labels for selected state).
Add a #Preview with realistic sample data.

This prompt fits naturally into the Build phase of a Soarias ship cycle — drop it in after your screens are scaffolded and data models are defined so Claude Code generates selection logic that compiles against your actual types.

Related

FAQ

Does this work on iOS 16?

Yes — List(selection:) with a Set binding was introduced in iOS 16. The #Preview macro requires Xcode 15+ to compile but the runtime behaviour works fine on iOS 16 devices. Just replace NavigationStack with NavigationView if you target below iOS 16.

Can I restrict the maximum number of selected items?

SwiftUI doesn't offer a built-in cap, but you can enforce one with .onChange(of: selection): if selection.count exceeds your limit, remove the most recently added ID. Since the Set doesn't preserve insertion order you'll need to maintain a separate @State private var ordered: [UUID] = [] array in parallel to know which ID to drop.

What's the UIKit equivalent?

In UIKit you'd set tableView.allowsMultipleSelectionDuringEditing = true, call setEditing(_:animated:) to enter edit mode, then collect selected rows from tableView.indexPathsForSelectedRows. The SwiftUI approach eliminates the delegate callbacks and index-path bookkeeping — the Set is your single source of truth.

Last reviewed: 2026-05-11 by the Soarias team.

```